# Preferences and consent (area 5)

The preference page can work perfectly while the programme fails: a nightly sync restores "subscribed", a second profile keeps receiving, or a sender enforces a narrower scope than the page promises. The audit proves enforcement in every sender. It does not choose the consent model; the privacy owner does, and the audit compares configuration with that written decision.

Platform facts were read on 2026-11-01 unless marked "verify live" or attributed to braze-implementation-qa (checked 2026-10-17).

## Build the preference map

One bullet group per customer-facing control: each preference centre topic, each channel toggle, the global opt-out, the footer link, the one-click header, SMS keywords, in-app settings and support requests. For each, record:

- the scope it promises, in the customer's words ("no product tips by email from Acme")
- the field or setting in every sender that can reach the person
- the authoritative writer of that field, and every other writer (syncs, imports, integrations, API jobs)
- the propagation path and its schedule
- failure handling: whether a failed write to a sender is retried and alerted, and whether optional sends are held until that sender confirms the change; if none of these happens, rate PC01 WARN
- the seed test that proves it (S03 to S07, S11)

Example for Acme (fictional):

- Control: "Acme Monthly" toggle in the preference centre.
  - Promised scope: no newsletter email from Acme.
  - HubSpot: subscription type "Acme Monthly". Customer.io: topic newsletter.
  - Writers: HubSpot preference page (customer), nightly warehouse sync of marketing_opt_in (system).
  - Path: HubSpot to the warehouse (hourly), warehouse to Customer.io and HubSpot (nightly, 02:00 UTC).
  - Seed: S03 and S11.

## Platform preference models

### Customer.io

- The `unsubscribed` attribute is a master switch for email, SMS, WhatsApp and push. In-app and Slack messages still deliver.
- Topics say what a message is about; channels say how it arrives. With the subscription centre on, a person must be subscribed to both the topic and the channel. There is no per-topic-per-channel preference, so "tips by email but events by SMS" cannot be represented natively.
- Topics are keyed by numeric ID. Opt-out topics subscribe people by default.
- Consent records are history for audit; they do not decide who receives messages. Open-tracking consent does not affect delivery.
- Webhooks bypass all subscription checks.

### Klaviyo

- Email and SMS consent states: SUBSCRIBED, NEVER_SUBSCRIBED, UNSUBSCRIBED. Implied consent stays NEVER_SUBSCRIBED.
- Adding a profile to a list or removing it does not change consent.
- Suppression is separate from consent; a USER_SUPPRESSED profile gets no email marketing.
- SMS marketing consent must be explicit.
- Click-tracking consent is separate from email marketing consent.
- Account default opt-in process: verify live per list.

### HubSpot

- Per subscription type: Subscribed, Unsubscribed or Not Specified. Whether Not Specified contacts can be emailed depends on the account's data privacy settings; never read it as an opt-in.
- "Unsubscribed from all email" is a separate contact property. Brands carry their own subscription types.
- A workflow's marketing email action skips non-marketing contacts and does not resend when the status changes. Marketing contact status is a billing setting, not permission.

### Braze (sibling skill)

- Email "subscribed" is the default for a new profile, so it is not evidence of consent. Only "opted_in" records an explicit confirmation.
- SMS, WhatsApp and LINE need a subscription group to send.
- A hard bounce marks the address invalid and does not change the subscription state.
- A campaign set to send to all users including unsubscribed users needs a documented reason.

### Iterable (read 2026-11-06)

- Message channels split marketing and transactional per medium. Message types carry an opt-in, opt-out or (for SMS) double opt-in policy, and users receive an opt-out type by default.
- Preference centres can be hosted or custom (verify live).

## Stale-write detection

message_pressure.py replays the preference log per profile in time order:

- An "off" in a scope (global, topic:<id>, channel:<name>) starts a restriction.
- An "on" from a customer-action source lifts it: preference_centre, one_click, link, support, in_app, sms_keyword.
- An "on" from crm_sync, warehouse_sync, import, api_sync, or any unclassified source does not lift it. It is listed as a suspected stale write (PC01 FAIL), and any later send in that scope is PC02 FAIL at any delay.
- Without a stale write, a send's delay after the "off" decides its label: more than 48 hours is PC02 FAIL; more than 1 hour and up to 48 hours is PC03 WARN evidence, with the sender and hours; 1 hour or less is listed as in flight (INFO), because a batch already queued can still go out. `--grace-hours` and `--latency-fail-hours` change the two limits; keep the defaults unless decisions.md sets a stricter target.
- When a send matches several scopes, the worst decides: a stale write first, then the earliest opt-out, so a fresh global opt-out cannot hide a topic opt-out ignored for days.
- Texts get no grace: any SMS or WhatsApp marketing send after an opt-out in a matching scope is PC08 FAIL. A text of any other class after a STOP (source `sms_keyword`, in the channel or global scope) is PC08 FAIL too, apart from one confirmation text within 5 minutes of the STOP (an audit default).
- These checks read every marketing-class send on every channel, whatever `--classes` and `--channels` say, so a security alert email to an opted-out person is never counted. Texts after STOP are the one exception above.
- The script prints NOT CHECKED instead of PASS for a topic opt-out that no marketing send carries (topic IDs differ between the logs, or the send log has no topic column) and when no preference-log key appears in the send log (keys or salts differ). Fix the mapping and rerun before rating PC02.
- Pass `--trust-sync-on` only after confirming that a sync relays genuine customer choices (for example the product's own preference page written through the warehouse). Trusted changes are then listed, not failed.

The preference log is pseudonymous: profile_key, changed_at, scope, choice, source. Confirm with seed S11: opt a seed out, wait past the next scheduled sync, and check the state in every sender.

## Unsubscribe latency (PC03)

Measure with seed S04: the time from the one-click or link action to suppression in every sender that can reach the address.

- 1 hour or less in every sender: PASS (the audit's target).
- Over 1 hour and up to 48 hours: WARN.
- Over 48 hours, or never: FAIL.
- Why: Gmail's Postmaster compliance table lists honouring unsubscribes as a bulk sender requirement, and its troubleshooting text says the entry appears when requests are not honoured within 48 hours. Yahoo requires honouring within 2 days.

## Transactional classification

A promotion sent as transactional bypasses preferences and caps. Check per platform:

- Braze: campaigns and Canvases set to send to all users including unsubscribed users.
- Klaviyo: messages marked transactional (also exempt from Smart Sending).
- Customer.io: transactional messages and webhooks (webhooks bypass subscription checks).
- HubSpot: transactional email usage (an add-on in some accounts; verify live).
- Iterable: campaigns on a transactional message channel.
- Then read the content: a feature announcement, cross-sell, cart reminder or discount inside a transactional template is PC07.

## SMS

- Marketing SMS needs explicit consent.
- STOP must reach every sender that can text the number: the SMS provider, the lifecycle platform and any CRM that sends SMS. Provider keyword settings and carrier rules: verify live in the provider console.
- Seed S07 needs a controlled number the user enters themselves; never paste numbers into chat.

## Regional policy

decisions.md should record, per region and channel: the consent model (explicit opt-in or opt-out), the treatment of default states, which message classes are exempt, record-keeping needs, and the privacy owner's sign-off with a date. The audit reports gaps against that record and never fills them with its own legal view.

## Rules

### PC01 authority

- WARN: no authoritative writer recorded per scope.
- FAIL: a sync, import or integration reversed an "off" with no customer action between (stale write).

### PC02 sends-after-opt-out

- FAIL, P0: an optional send in the scope more than 48 hours after the change, or at any time after a stale write reversed it, from any sender.
- Sends more than 1 hour and up to 48 hours after the change are PC03 evidence; sends within 1 hour are in flight.
- Detect: message_pressure.py with `--prefs`. Each example shows the sender, journey, scope, the delay after the change and any stale write that reversed it.

### PC03 unsubscribe-latency

- Bands above. Evidence: seed S04 timestamps per sender with timezone.
- message_pressure.py adds evidence from real logs: marketing sends more than 1 hour and up to 48 hours after a change, per sender (WARN).

### PC04 scope-mapping

- FAIL: a visible control's promised scope differs from what a sender enforces: a topic toggle that only one sender reads; Customer.io's global unsubscribe still allowing in-app messages when the page says "all messages"; a webhook-delivered message ignoring the topic.
- Confirm: seeds S03 and S05.

### PC05 defaults-are-not-consent

- WARN: default-state profiles receive marketing in regions where the policy is unclear.
- FAIL: decisions.md requires explicit opt-in for a region, and default-state profiles there receive marketing. Default states: Braze email subscribed without opted_in, HubSpot Not Specified, Klaviyo NEVER_SUBSCRIBED, Customer.io opt-out topics.

```sql
-- PC05: marketing sends in explicit opt-in regions to profiles whose state is only a platform default.
-- Map each platform's default state to 'default_only' in the profiles model first.
select p.region, s.sender, count(*) as sends_to_default_state
from sends s
join profiles p on p.profile_key = s.profile_key
where s.message_class = 'marketing'
  and p.region in ('UK', 'EU')
  and p.email_consent_basis = 'default_only'
group by 1, 2;
```

### PC06 records-versus-enforcement

- WARN: consent history is missing where the policy needs it, or the history is treated as the enforcement mechanism (Customer.io consent records do not decide delivery).

### PC07 transactional-misuse

- FAIL: a promotional journey classed transactional, or set to send to unsubscribed users.
- Detect: inventory_audit.py PC07, then read transactional templates for offers.

### PC08 sms-consent-and-stop

- FAIL: any SMS after STOP from any sender, at any delay, or SMS marketing without explicit consent. One confirmation text within 5 minutes of the STOP is allowed.
- Detect: message_pressure.py with `--prefs` lists SMS and WhatsApp marketing sends after any opt-out, and texts of every class after a STOP beyond the confirmation, each with its class; the consent records behind each opt-in are checked by hand.
- Before raising a flagged service text as P0, read it. A one-time passcode the person requested after STOP, sent from a separate programme the STOP did not cover, is recorded with that programme and the request as evidence instead; anything else stays FAIL.
- NOT CHECKED with the reason when no SMS sender is in scope.

### PC09 duplicate-profile-leak

- FAIL: marketing reaches an address or number that is unsubscribed on another profile. Detect with the TD07 query; confirm with seed S06.

### PC10 regional-policy-documented

- WARN: no consent model per region and channel signed by the privacy owner.

Designing a preference centre or a consent service belongs to preferences-caps-and-suppression (planned). Until it ships, the audit records the gaps and change requests only.
