Skip to main content

Knowledge > Products > WiseAI Realtor > Identity, Consent & Suppression

WiseAI Realtor — Contact Identity, Consent & Suppression Model

Design-first spec. No code, no migrations. This closes backend-audit P0 #2 (identity/dedup/merge) and P0 #3 (contact-level consent + cross-channel opt-out gating the AI). It assumes the data-model doc (P0 #1) and auth/RBAC (P0 #4) are written alongside.

This is product requirements, not legal advice. Every item tagged [LEGAL] is a flag for counsel/brokerage, not a settled position. CASL/PIPEDA/Law 25 wording, expiry interpretation, retention windows, and import-consent posture are counsel's call before any client goes live.


0. The problem, in one paragraph

Today one human who scans a yard-sign QR, calls the voice line, and submits the website form creates three disconnected rows — a local_business_leads row from the form/chat, a voice_callback_requests/voice_visitor_contacts row from the call, and another local_business_leads row from the QR-chat. There is no long-lived person, so there is no relationship memory across deals/years, and — critically — no contact-level consent record that the AI can check before it emails, texts, or calls that person back. The platform has an excellent email suppression spine and a Reviews-Engine business-scoped suppression table, but nothing ties a person's consent and opt-outs together across all channels, and nothing forces the AI itself through that gate. This spec defines the contact spine, the consent ledger, the universal suppression gate, and the data-subject-rights/retention layer that sit underneath every other Realtor screen.


1. Existing infrastructure to REUSE (do not rebuild)

Verified present 2026-06-27. The spec extends these; it does not replace them.

CapabilityExisting assetReuse decision
Phone → E.164 normalizationsrc/lib/local-business/validators.tsnormalizePhone(), isE164Phone() (TS); voice-agent-livekit/verticals/local_business/tools.py_normalize_phone() (Python, used verbatim by verticals/real_estate/)Canonical normalizer for all identity + suppression keys.
Email normalizationvalidators.tsnormalizeEmail() (trim + lowercase; does not strip +-tags/dots, by design); same normalizeEmail() in src/lib/outreach/suppression-gate.tsCanonical email key.
Business-scoped suppression spinelocal_business_client_suppressions (business_id, contact_email, contact_phone, channel ∈ {email,sms,all}, reason ∈ {unsubscribe,sms_stop,bounce,complaint,manual}); gate src/lib/local-business/server/client-suppressions.tsisSuppressed(), suppress() (idempotent, partial-unique on (business_id, lower(email)) and (business_id, phone), handles 23505 races)This is the suppression backbone. Extend it (§3), don't fork it.
Platform email suppression spineemail_unsubscribers (email, property, suppression_type ∈ {unsubscribe,bounce,complaint,manual_dnc}, resubscribed_at); loadGlobalSuppressionSet() / isGloballySuppressed() in suppression-gate.ts; per-property gate src/lib/email-dnc-gate.tsshouldSkipLifecycleContact()Keep in sync for email so a person stays suppressed across properties (§3.5).
Email send gatesrc/lib/outreach/safe-send.tssafeSendBatch()/checkGates() (NeverBounce deliverability + bounce-rate auto-pause + global-suppression)The email leg of the universal gate (§3.6) wraps this.
Resend webhook → suppresssrc/lib/outreach/resend-webhook.ts (/api/webhooks/resend/events): bounce → suppress, complaint → suppress+withdraw; matches on resend_message_idRepoint its effects at the consent ledger + extended suppression spine.
CASL consent fields (already shipped, Reviews Engine)local_business_review_requests.ebr_basis ∈ {purchase_24mo, inquiry_6mo, written_contract_24mo, express}, .ebr_date, .opted_out_at, .unsubscribe_token; local_business_review_settings.casl_sender_name, .casl_mailing_address, .sms_from_numberThe consent ledger (§2) generalizes this exact vocabulary; the CASL footer fields are reused for every outbound, not just reviews.
Per-interaction lead rowslocal_business_leads (business_id, source ∈ {voice,chatbot,form,sms,email,manual,appointment,review_feedback}, source_record_id, contact_name/email/phone, lifecycle timestamps); dedup today only within a voice session via (business_id, source_record_id)Becomes a child interaction of the new contact spine (§1.2).
Ephemeral call rowsvoice_callback_requests, voice_visitor_contacts (caller_name/email/phone, source)Re-parented to a contact on capture (§1.4).
Tenant recordlocal_businesses (vertical='real_estate', primary_contact_*, address, timezone)The business_id tenant boundary for everything below.

Gaps this spec fills (all greenfield today): a long-lived person record, cross-row dedup/merge, a consent ledger (vs. the single per-request ebr_* fields), SMS STOP/HELP handling, voice DNC + National DNCL, a universal gate the AI must call, and data-subject-rights/retention/auto-purge.


2. Contact identity resolution, dedup & merge

2.1 The contact spine (new)

Introduce a long-lived, tenant-scoped person record. Proposed name local_business_contacts (stay in the local_business_* family so it serves every vertical, not realtor-only). Decision flag (§5): new table vs. overloading local_business_leads — recommend new; leads/calls become interactions that point at it.

Core fields (illustrative, not a migration):

  • id, business_id (tenant boundary)
  • Identity: display_name, first_name, last_name, primary_email, primary_phone_e164, plus alt_emails[], alt_phones_e164[] (a couple shares one phone; a person has work+personal email)
  • Match keys (derived, indexed): email_norm, phone_e164, name_norm (lowercased, diacritics-folded, whitespace-collapsed), address_norm (Canada-Post-style)
  • Relationship: lifecycle_status (§2.6), language_pref, owner_agent_id (which agent owns the relationship — Team Beckett is two agents), household_id (nullable; links spouses/co-buyers without merging identities)
  • Provenance: first_source, first_seen_at, last_activity_at
  • Merge bookkeeping: merged_into_id (nullable soft-tombstone), merge_state ∈ {active, merged, archived}
  • metadata (jsonb), timestamps

Every interaction row (local_business_leads, voice_callback_requests, voice_visitor_contacts, review requests, future deals/tasks/sms_messages) gains a nullable contact_id FK. Consent and suppression are keyed on the contact, so they survive merges.

2.2 Normalization (before any match)

  • Phone: normalizePhone()isE164Phone() (TS) / _normalize_phone() (Python). Store E.164. Reject non-E.164 from matching (still store raw on the interaction for the agent to read).
  • Email: normalizeEmail() (trim + lowercase only). Do not strip +-tags or dots — they are deliverability-significant and two real inboxes can differ by them.
  • Name: lowercase, fold diacritics, collapse whitespace, drop honorifics/suffixes for the compare key (keep original for display).
  • Address: normalize unit/street/postal to a canonical string; postal code is the high-signal token.

2.3 Match rules → confidence tiers

On every new interaction (QR/call/form/import), run resolution against existing contacts in the same business_id:

Strong (deterministic) keys

  • Exact phone_e164 match.
  • Exact email_norm match (against primary or alt).

Weak (probabilistic) signals

  • Fuzzy name_norm (Jaro-Winkler or trigram ≥ ~0.9) + matching address_norm or postal.
  • Fuzzy name + same listing/MLS context or same owning agent.
  • Partial phone (last 7) + fuzzy name.

Tiers and actions

TierConditionAction
AUTO-ATTACHExactly one existing contact matches on a strong key, and name_norm does not strongly conflictAttach the interaction to that contact; no human step.
REVIEW — possible same personWeak-only match; OR strong key matches but name_norm strongly conflicts (e.g. two first names on one phone → likely a household)Create the interaction + a provisional contact, queue a merge/link suggestion.
REVIEW — conflicting strong keysPhone matches contact A and email matches a different contact BQueue a merge of A and B for an agent (never auto-merge two histories).
NEWNo match ≥ thresholdCreate a new contact.

Hard rule: never auto-merge two pre-existing canonical contacts. Each may carry independent consent and transaction history; a wrong merge is a privacy breach and a consent contamination. Auto-attach only ever links a brand-new interaction to one existing contact. Everything that would combine two existing people goes to the review queue.

Household nuance: a shared phone/email with different names is common in real estate (couples). Default to link as household (household_id), not merge. Offer merge only when the agent confirms it is genuinely the same person.

2.4 Re-parenting on attach/merge

  • On attach: set contact_id on the interaction; update contact last_activity_at, language, and any newly learned alt email/phone; insert a consent event if the interaction establishes a basis (§2 → §2 consent).
  • On merge (loser → winner): repoint contact_id on all interactions/leads/calls/reviews/deals/tasks/lead_events; union the consent ledgers; recompute effective consent and effective suppression with strictest-wins (any suppression on either side survives — a merge can never loosen an opt-out); set loser merged_into_id = winner, merge_state='merged' (soft tombstone, reversible); record a negative-match if the agent says "not a match" so the pair stops being re-suggested.

2.5 Merge UI spec (what the agent sees and decides)

A Merge / Resolve Duplicates surface (lives under Contacts; suggestions also surface inline on the contact drawer and the dashboard "needs attention").

Per candidate pair/cluster:

  1. Side-by-side cards (2..N) with photo/initials, name, primary email/phone, address, lifecycle status, owning agent, source, counts (interactions, deals).
  2. Why suggested — explicit match reasons + score: "Same phone +15195551234; names 'Rob Smith' vs 'Robert Smith' (0.91); same postal N4S 1A1."
  3. Field-level survivorship picker — for name, primary email, primary phone, address, language, owner agent, tags: pick the winning value; non-winning values are retained as alternates (no data loss).
  4. Merged-timeline preview — both histories interleaved chronologically so the agent sees what the unified record will look like.
  5. Consent & suppression reconciliation panel — lists each side's consent events and suppressions; shows the computed post-merge effective state. A blocking warning if the merge would touch a suppression; suppressions are union, strictest-wins, never relaxed.
  6. Decision buttons:
    • Merge (same person) → §2.4 merge.
    • Link as household → set shared household_id, keep identities separate.
    • Not a match → dismiss + record negative-match (won't resuggest).
  7. Audit + reversibility — who/when, before/after snapshot stored; merges reversible within a grace window (§4 mirrors purge reversibility). RBAC: who can merge (and whether cross-agent merges need the brokerage role) is set by the auth/RBAC spec (P0 #4).

2.6 Lifecycle: lead → client → past client (across deals/years)

lifecycle_status on the contact (relationship state; deals are separate child records in the Active Business Hub, so one contact can have many deals over years):

leadnurtureactive_clientunder_contractpast_client → (re-engages) active_client again · plus sphere (sphere-of-influence/SOI, no active intent), dormant, do_not_contact (set by suppression, §3).

  • A past client who starts a new search re-enters as active_client on the SAME contact — that continuity is the relationship-memory moat (anniversaries, prior addresses, referral history).
  • Each new inquiry or transaction re-arms implied consent (§2 consent) by inserting a fresh consent event — lifecycle transitions and consent windows are linked but distinct.

One immutable row per consent change, contact- and business-scoped. Append-only; "current consent" is a derived view, never an editable column.

Fields (illustrative):

  • id, business_id, contact_id
  • channel ∈ {email, sms, voice_call, mail, all} — note voice_call = live human/AI call; automated/robocall (ADAD) is a separate, stricter regime (§3.4)
  • action ∈ {granted, withdrawn, expired, reaffirmed}
  • basis ∈ {express, implied_inquiry, implied_transaction, implied_existing_business, conspicuous_publication} (CASL bases; generalizes the shipped ebr_basis vocabulary)
  • source ∈ {web_form, qr_scan, voice_capture, chat_capture, phone_verbal, paper_form, csv_import, concierge_onboard, referral, reply_email, manual_agent}
  • source_detail (jsonb): form id, listing/MLS, campaign id, call/session id, importer + import_id, agent who entered it
  • consent_text_version (the version id of the exact checkbox/disclosure wording shown) and consent_text_snapshot (the literal text captured at the moment — CASL puts the burden of proof of consent on the sender, so we store the evidence)
  • language (en/fr — Law 25/Bill 96)
  • occurred_at, effective_from, expires_at (computed for implied; null for express until withdrawn)
  • withdrawn_at, withdrawal_source
  • Evidence: ip, user_agent (web); recording_ref (voice)
  • created_by, created_at

Effective consent is a derived function effectiveConsent(contact_id, channel) → {state, basis, expires_at} computed from the latest events: express-granted-and-not-withdrawn ⇒ allowed; implied-and-not-expired-and-not-withdrawn ⇒ allowed; otherwise none. Withdrawal beats everything.

  • Express — affirmative opt-in (checkbox unchecked by default, clear purpose, full sender identification). No expiry, but must record wording + remain withdrawable.
  • Implied — inquiry (implied_inquiry): a person inquires (form submit, inbound call/chat) ⇒ implied consent for 6 months from the inquiry date.
  • Implied — transaction / existing business relationship (implied_transaction): a purchase/contract/closed deal ⇒ implied consent for 24 months from the transaction date.
  • Re-arming: every new qualifying inquiry/transaction inserts a new granted event that resets the relevant window. (Mirrors the shipped purchase_24mo / inquiry_6mo / written_contract_24mo / express semantics — now as a time-series, not a single field.)

Expiry job (recompute, NOT a send): a scheduled task recomputes effective consent and writes action='expired' events when expires_at passes. This is a state recompute only — it never sends anything, so it does not violate the "no cron sends campaigns" rule (CLAUDE.md / feedback_never_cron_send_campaigns). The gate (§3.6) re-checks live anyway; the job exists for accurate dashboards and pre-expiry nudges to the agent (not the contact).

  • PIPEDA: meaningful consent for collection/use/disclosure, purpose limitation, withdrawable consent, access + correction rights (§4). Consent text must state purpose at point of collection.
  • Quebec Law 25 / Bill 96: stricter — express + granular (separated) consent per purpose, consent separate from other terms, privacy-by-default, profiling disclosure + opt-out, French-language wording for QC contacts. Recommendation: detect province (from contact address / brokerage jurisdiction) and for QC contacts prefer express, granular, French consent and do not rely on implied consent for marketing/profiling. consent_text must carry an fr variant. Gate marketing on QC contacts to express-only until counsel rules otherwise.

3.4 Channel notes

  • voice_call consent = permission to place a live call to that number. Automated/robocall (ADAD) and SMS marketing are stricter (CRTC + National DNCL) — see §3 outbound posture and §3.5 SMS.
  • mail (physical) is generally outside CASL but still honors internal DNC and PIPEDA.

CSV import and concierge onboarding are the riskiest consent surface. Never assume express consent on import. The import/onboard wizard must force a per-contact (or per-batch-with-attestation) declaration:

Importer assertsMapped consent eventResulting outbound permission
Documented express consent exists (agent attests; evidence ref captured)granted, basis=express, source=csv_import, consent_text_version='imported-attested'Allowed (agent bears proof).
Known transaction/closed-deal dategranted, basis=implied_transaction, expires_at = txn_date + 24moAllowed only if window not already expired.
Known inquiry dategranted, basis=implied_inquiry, expires_at = inquiry_date + 6moAllowed only if not expired.
Nothing knownno consent event → contact flagged consent_unknownNo outbound marketing. Only an agent-initiated, manual, transactional/relationship message — and note you generally cannot email to ask for consent without consent.

Default to consent_unknown when the importer leaves basis/date blank. Surface a per-tenant "X imported contacts have unknown consent — not mailable" warning. This whole table is a [LEGAL] flag.


4. Global cross-channel suppression — the universal gate

4.1 Principle

One suppression check gates every outbound message on every channel, and the AI is just another sender. The chat agent's "I'll text you the listing," the voice agent's callback, the review cadence, the newsletter, a drip — all call the same function and obey the same answer. There is no path to the wire that bypasses the gate.

4.2 Extend the existing spine (don't fork)

Extend local_business_client_suppressions:

  • channel enum → add voice (currently {email, sms, all}).
  • reason enum → add unsubscribe_email, bounce, complaint, sms_stop, voice_dnc, national_dncl, manual (keep existing values).
  • Add contact_id FK (so suppression re-parents on merge, §2.4) while keeping the (business_id, lower(email)) / (business_id, phone) identifier match for pre-contact captures.
  • Uniqueness becomes per-channel: (business_id, identifier, channel) so a person can be SMS-suppressed but email-OK; channel='all' is the master kill-switch. Keep idempotent insert + 23505-race handling already in suppress().
  • Resubscribe = a new consent granted event + remove/expire the suppression row (mirror email_unsubscribers.resubscribed_at semantics).

4.3 Channel mechanics

  • Email — unsubscribe via one-click List-Unsubscribe header and footer link (reuse unsubscribe_token pattern) ⇒ suppression (email, reason=unsubscribe_email) + consent withdrawn. [LEGAL] CASL: a withdrawal must be honored within 10 business days, and the unsubscribe mechanism must stay functional for at least 60 days after the message is sent — we honor instantly and keep tokens valid well past 60 days. Hard bounce ⇒ suppress; complaint ⇒ suppress + withdraw. Reuse resend-webhook.ts — repoint its bounce/complaint effects at the extended spine + ledger, and keep writing email_unsubscribers (§4.5).
  • SMS (greenfield — no Telnyx/Twilio inbound handler today) — inbound STOP/UNSUBSCRIBE/ARRÊT ⇒ suppress (phone, sms, reason=sms_stop) + consent withdrawn + send the single allowed confirmation. HELP/AIDE ⇒ info reply. START/UNSTOP ⇒ resubscribe. STOP/HELP keyword handling is mandatory (carrier/CTIA + Canadian rules). Needs a new provider inbound webhook.
  • Voice — in-call "don't call me again" (caught by the agent or flagged by the human) ⇒ suppress (phone, voice, reason=voice_dnc) + consent withdrawn. National DNCL [LEGAL]: before any outbound AI call/text, the number must be scrubbed against Canada's National DNCL (CRTC), plus internal DNC, calling-hour windows, and ADAD rules for any automated voice. MVP recommendation: stay inbound-only (no outbound AI calling/texting) — this sidesteps DNCL/ADAD/calling-hours entirely (echoes backend-audit P0 #6). Gate outbound voice/SMS behind a dedicated compliance layer if/when it ships: DNCL subscription + ≤31-day scrub cadence + hour windows + ADAD identification.
  • Mail/print — honor internal DNC; outside CASL.

4.4 The gate function (every sender calls it)

A single server-side primitive, e.g. assertCanContact(businessId, {contactId | email | phone}, channel) → { allow: boolean, reason }:

  1. Normalize identifiers (normalizePhone/isE164Phone, normalizeEmail).
  2. Suppression — look up the extended spine for (business, identifier, channel) and (business, identifier, all). Any hit ⇒ DENY (reason=suppressed_<...>).
  3. ConsenteffectiveConsent(contact, channel). No valid/active consent ⇒ DENY (reason=consent_required|consent_expired). QC contacts: require express (§3.3).
  4. Deliverability — email: NeverBounce via safe-send.ts/suppression-gate.ts (valid|catchall|unknown); SMS/voice: valid E.164 + not a known-invalid line.
  5. Outbound-regime guard — if channel ∈ {voice, sms} and the message is outbound-initiated, require the outbound-compliance flag (DNCL scrub etc., §4.3) or DENY.
  6. Log the decision (audit row) regardless of outcome.

Every path calls it: email composer, the SMS/text-ISA lane, the review-request cadence, any outbound voice — and the AI agents. The voice/chat "text me / email me the details" tools MUST (a) call assertCanContact first, and (b) capture a fresh consent event — the act of the visitor giving a number to be texted is itself express consent for that message, recorded with source=chat_capture|voice_capture. On DENY the AI must not send; it should say so and fall back to an allowed channel or to a human. The gate is necessary but not sufficient — bulk/campaign sends still require founder/agent-in-the-loop; no cron may send (CLAUDE.md feedback_never_cron_send_campaigns).

4.5 Email: keep the platform spine in sync

For the email channel, also write email_unsubscribers (property tag e.g. wiseai_realtor or per-tenant) when a contact unsubscribes/bounces/complains, so a person stays suppressed across properties and the existing safe-send.ts gate sees them. Decision flag (§5): business-scoped only vs. also platform-wide — recommend both for email (per-tenant suppression + platform safety net), watching the cross-property link-leak trap (feedback_outreach_links_per_property_host).


5. Data-subject rights + retention / auto-purge

A Data-Subject Request (DSR) workflow in the backend: intake (privacy@ + in-app), identity verification (don't hand a person's data to an impersonator), queue with SLA timer, fulfillment, audit + response log.

  • Access — export everything held for a contact: profile, all interactions, transcripts/summaries, consent history, suppression state, analytics. A per-contact "Export data" action + request intake. PIPEDA ~30 days; Law 25 has its own timeline.
  • Correction / rectification — edit fields; corrections logged (Law 25 right to rectification).
  • Deletion / erasure — Law 25 right to erasure/de-indexing; PIPEDA withdrawal + disposal. Hard-delete or irreversibly anonymize the contact + interactions + transcripts, except records under legal hold (§5.3).
  • Portability (Law 25) — structured, commonly used export format.
  • Withdrawal of consent — routes to §3 suppression + a withdrawn event.
Data classDefault postureNotes
Raw call audio / chat transcriptsShort (e.g. 30–90 days raw audio; keep AI summary longer)Recording requires a disclosure at capture (Canada one-party, but disclose; Law 25). Auto-purge raw, retain summary + metadata.
Contact + interaction recordsWhile relationship active + a dormant tail (e.g. dormant + N years) unless legal holdTail length is a tenant/legal decision.
Consent events (proof)Retain beyond contact deletion — keep proof of consent + a tail after withdrawal (e.g. ~3 yrs)CASL puts proof-of-consent burden on the sender; do not purge evidence with the person.
Suppression recordsRetain indefinitely, even if the contact is deletedA deleted person must STAY un-contactable. Store a one-way hash of phone/email so a later inbound can't accidentally re-contact a prior complainer.
Analytics events (lead_events)Pseudonymize after a windowKeep aggregate value, drop direct identifiers.
Transaction / brokerage recordsLegal hold — excluded from deletionProvincial real-estate record-retention + FINTRAC (~5 yrs) [LEGAL]. Surface to the requester what is retained and why.

5.3 Auto-purge job

A scheduled recompute/delete task (allowed — it sends nothing): soft-delete past the retention window → hard-delete/anonymize after a reversible grace window; respects legal holds; never purges consent proof or suppression (the deletion-vs-suppression tension above); logs every purge for audit. Reversibility grace mirrors the merge-undo window (§2.5).


6. Open questions, decisions & risks

Decisions to lock

  1. New contact spine vs. overload local_business_leads — recommend new local_business_contacts; leads/calls become interactions with contact_id.
  2. Generic (local_business_*) vs. realtor-only naming/scope — recommend generic (reusable across verticals).
  3. Household/couple modelinghousehold_id link as default; merge only on agent confirmation. Shared phone/email is common in RE.
  4. Email suppression scope — business-scoped only vs. also platform-wide email_unsubscribers — recommend both (watch link-leak trap).
  5. Outbound posture — keep inbound-only for MVP (sidesteps DNCL/ADAD/CRTC) — recommend yes; treat outbound voice/SMS as a separate compliance-gated epic.
  6. Province gating — auto-detect QC and force express + granular + French consent; how to detect province reliably (contact address vs. brokerage jurisdiction)?
  7. Match thresholds — fuzzy name/address thresholds need tuning on real data; pick conservative defaults (favor review over auto-merge).
  8. Contact ownership under RBAC — is a contact owned by an agent or shared at the brokerage (Team Beckett = 2 agents)? Consent/suppression are brokerage-scoped (business_id); attribution is per-agent. Depends on the auth/RBAC spec (P0 #4).

Risks

  • Wrong-merge = privacy breach + consent contamination (one person's data shown about another; suppression/consent blended). Mitigation: never auto-merge two existing contacts; strictest-wins suppression on merge; reversible merges; negative-match memory.
  • Missed suppression = CASL violation (penalties to ~$10M). Mitigation: single universal gate, AI included; deny-by-default; suppression survives deletion via hash.
  • Assuming consent on import = CASL violation. Mitigation: consent_unknown default; per-contact basis+date declaration; QC express-only. [LEGAL]
  • Outbound without DNCL scrub / ADAD compliance = CRTC penalty. Mitigation: inbound-only MVP; outbound behind a compliance layer.
  • Transcript/PII over-retention = Law 25 exposure. Mitigation: short raw-audio retention, auto-purge, recording disclosure.
  • Deletion that loses suppression = re-contacting a complainer. Mitigation: retain hashed suppression identifiers indefinitely.
  • DSR to an impersonator = breach. Mitigation: identity verification before fulfillment.
  • Performance — identity resolution runs on every QR/call/form insert; indexed email_norm/phone_e164 lookups + bounded fuzzy candidate set.

Implied-consent window interpretation (6-mo/24-mo); imported-contact consent posture (and whether a consent-request email is itself permissible); National DNCL + ADAD + calling-hours for any outbound; Law 25 applicability thresholds + French wording; recording-consent disclosure per province; retention windows for transcripts, consent proof, and transaction/FINTRAC records; the deletion-vs-legal-hold matrix.


7. Build sequence (fits backend-audit "systems layer first")

  1. Contact spine (local_business_contacts) + contact_id on all interaction tables + backfill/attach.
  2. Identity resolution at the 3 capture points (QR/voice/form) using the existing normalizers → auto-attach + review queue.
  3. Merge UI + re-parenting + negative-match memory.
  4. consent_events ledger + effectiveConsent() view + expiry recompute job; generalize the shipped ebr_* vocabulary.
  5. Extend local_business_client_suppressions (voice channel, reasons, contact_id, per-channel uniqueness); keep email_unsubscribers in sync.
  6. assertCanContact() universal gate; wire every sender + the AI tools through it; repoint resend-webhook.ts.
  7. Import/concierge consent wizard (basis+date+attestation, consent_unknown default).
  8. DSR workflow + retention windows + auto-purge job (legal holds, suppression/consent-proof exempt).

Summary (4 lines)

  1. Identity: add a long-lived per-tenant local_business_contacts spine; QR/voice/form rows become interactions linked by contact_id via E.164/email normalizers — auto-attach on a single strong-key match, route fuzzy/conflicting/two-existing-contact cases to a review-queue merge UI (field survivorship + consent reconciliation, strictest-suppression-wins, reversible); lifecycle lead→active→past-client persists across deals.
  2. Consent: append-only consent_events ledger (channel, basis, source, wording version+snapshot, expiry) implementing CASL implied windows (6-mo inquiry / 24-mo transaction, re-armed per event), express+granular+French for Quebec Law 25, and a default-consent_unknown posture for imports.
  3. Suppression: extend the existing local_business_client_suppressions spine to all channels (email/SMS/voice) and gate every outbound — including the AI's text/email/callback tools — through one assertCanContact() function (suppression + consent + deliverability + DNCL), MVP inbound-only to sidestep National DNCL/ADAD.
  4. Rights + risk: PIPEDA/Law 25 access/correction/deletion via a verified DSR workflow with class-based retention + auto-purge (raw audio short; consent-proof and suppression retained even past contact deletion via hashed identifiers); top risks are wrong-merge breaches, missed-suppression CASL fines, and assumed-consent imports — all [LEGAL] items need counsel sign-off before go-live.