# Frequency caps and suppression (area 4)

A cap inside one tool says nothing about what the customer received from all tools together. Every sender can be within its own limit while the person gets five optional messages in a day. This file holds the policy fields, the platform semantics that change findings, the send log method and rules FC01 to FC09.

Platform facts were read on 2026-11-01 (HubSpot, the Braze setting for exempt streams and the Customer.io WhatsApp rule on 2026-11-02) unless marked "verify live" or attributed to the sibling braze-implementation-qa skill (checked 2026-10-17).

## Policy fields (FC01)

A written cap policy names:

- identity: person, profile, address or account that the cap counts against
- channels counted, and whether the limit is shared or per channel
- counted classes (usually marketing only) and exempt classes with the reason for each
- window type: calendar day in the recipient's timezone, or rolling hours
- limits per window, for example 2 per rolling 24 hours and 5 per rolling 7 days
- blocked-message treatment per message type: discard, reconsider later with a fresh eligibility decision, or escalate the task through another route
- the owner who approves exceptions

Without a written policy, message_pressure.py screens at 2 optional messages per rolling 24 hours and 5 per rolling 7 days. That is a screen to find heavy recipients, not a recommendation.

## Platform semantics that change findings

### Braze

- Global caps count calendar days in the user's timezone, not rolling hours (sibling skill).
- In-app messages and Content Cards neither count toward caps nor get capped.
- The most restrictive applicable rule wins.
- Campaigns and Canvases that do not obey caps also do not count toward them by default, but each campaign and Canvas can be set to count. Read that setting for every exempt stream before rating FC04: an exempt stream that does not count can push a user over the policy without the cap ever seeing it.
- API campaigns can set `override_frequency_capping`.
- A capped Canvas step advances the user to the next step; the user does not exit. Later copy must make sense if the earlier message never arrived.
- Rate limiting runs before the cap, and capped users are not replaced. With quiet hours, the cap is checked first and a capped user is skipped, not held.

### Customer.io

- A limit covers no message by default: it applies only once it is assigned to a workflow or a message. A limit that exists but is assigned to nothing is FC02.
- Windows are relative per person, counted from delivery timestamps. The maximum time frame is 31 days.
- In-app, inbox and Slack messages are not limited.
- WhatsApp counts toward the SMS limit only in workspaces that integrated Twilio before 30 July 2026; otherwise check WhatsApp's own limit assignment (verify live).
- Automatic retries of limit-blocked messages continue even after the person exits the automation. The retry window is at most 48 hours, and Customer.io advises a retry window at least as long as the limit's time frame. Turning auto-retry off lets already scheduled attempts finish.
- One-time sends and transactional messages are not auto-retried.

### Klaviyo Smart Sending

- Default windows: email 16 hours, SMS 24 hours, push 24 hours. Channels are independent, so an email does not use up the SMS window.
- Skipped messages are not rescheduled.
- Messages marked transactional are never skipped.
- On by default for campaigns; enabled in many pre-built flows but not in every flow message, so read each flow message.
- Changing the window does not apply retroactively.
- A marketing send with Smart Sending off still resets the timer for each recipient Klaviyo attempts to deliver to.

### HubSpot

- A workflow's sequence enrolment is skipped when the user exceeds the daily send limit: the contact skips the action, and HubSpot describes no next-day queue.
- A workflow continues after a marketing email is skipped for a non-marketing contact, and the email is not resent when the contact later becomes a marketing contact.
- The account-level marketing email frequency limit: read its label, scope and which emails count in the UI (verify live).

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

- Caps are set per channel and per message type; a message type value overrides the channel value, and they never add up to one overall limit. Read each channel's setting rather than assume caps exist.
- Campaigns, journeys or message types can be exempted, and transactional messages don't count.
- Skipped sends are logged with reason `FrequencyCapping`.
- Frequency Decisioning is a separate AI feature and logs `FrequencyOptimization`.

## Building the send log

One pseudonymous log for every sender, 30 days by default:

- `profile_key`: an internal ID or a salted hash, the same across senders. Never an email address or phone number; message_pressure.py stops with exit status 2 if it sees one and does not print it. Plain numeric CRM record IDs are allowed on email rows. On SMS and WhatsApp rows a bare 10 to 15 digit key also stops the run, because a spreadsheet may have dropped a phone number's + sign: hash SMS keys before export.
- `sent_at`: a timestamp with its offset. The script reads ISO 8601 with Z or an offset (`2026-10-08T07:00:00Z`, `2026-10-08T08:00:00+01:00`), Snowflake's default TIMESTAMP_TZ text (`2026-10-08 07:00:00.000 +0000`) and BigQuery's CSV text (`2026-10-08 07:00:00 UTC`). A timestamp with no offset is read as UTC and the output says so. The preference log's `changed_at` and the holdout dates use the same parser.
- `channel`, `sender`, `journey_id`, `message_class` (marketing, transactional, service or internal; any other value stops message_pressure.py with exit status 2 and lists the values).
- Set `message_class` per `journey_id` by purpose, from the journey inventory: the inventory's class, except that a journey PC07 flags (a promotion classed transactional or service, like Acme's cio-21) is marketing here. Never take it from the platform's transactional flag, or the PC07 defect hides its own sends from PC02, PC03, PC08, MH03, FC05 and JC07. Keep the mapping in a small `journey_classes` table; a journey missing from it comes out as `unclassified` and stops the script until someone classifies it.
- `topic`: the preference topic the message belongs to, mapped to the preference log's topic IDs across senders. Required when the preference log has topic scopes: the script reports a topic opt-out that no marketing send carries as NOT CHECKED, and PC02 then passes for global and channel scopes only.
- `recipient_tz` (optional): an IANA name such as Europe/London.

Combine senders in the warehouse. Column and table names below are placeholders; rename them to your exports:

```sql
-- One send log across senders. Hash inside the warehouse with a salt the data team holds.
-- Snowflake and Databricks: sha2(...); BigQuery: to_hex(sha256(...)).
-- journey_classes(journey_id, message_class): the class by purpose from the inventory, never the
-- platform's transactional flag. Unmapped journeys come out as 'unclassified' and stop the script.
select sha2(concat('<salt>', d.customer_id), 256) as profile_key,
       d.delivered_at as sent_at, d.channel, 'customerio' as sender,
       d.workflow_id as journey_id, coalesce(j.message_class, 'unclassified') as message_class,
       d.topic_id as topic, p.timezone as recipient_tz
from cio_deliveries d
join people p on p.customer_id = d.customer_id
left join journey_classes j on j.journey_id = d.workflow_id
where d.delivered_at >= current_date - interval '30 days'
union all
select sha2(concat('<salt>', e.customer_id), 256), e.sent_at, 'email', 'hubspot',
       e.email_campaign_id, coalesce(j.message_class, 'unclassified'), e.subscription_type_id, c.timezone
from hubspot_email_sends e
join crm_contacts c on c.contact_id = e.contact_id
left join journey_classes j on j.journey_id = e.email_campaign_id
where e.sent_at >= current_date - interval '30 days'
union all
select sha2(concat('<salt>', b.customer_id), 256), b.sent_at, 'email', 'billing',
       b.template_id, coalesce(j.message_class, 'unclassified'), null, null
from billing_emails b
left join journey_classes j on j.journey_id = b.template_id
where b.sent_at >= current_date - interval '30 days';
```

Map topic IDs in the same pass: if one sender keys topics by number and the preference log by name, translate them here, or every topic opt-out comes back NOT CHECKED.

For large programmes, run the script on a sample of profiles with all their sends (for example the profiles whose hash starts with one hex character, about 1 in 16), or run the same logic in SQL.

## Running message_pressure.py

```bash
python3 scripts/message_pressure.py sends.csv --cap-24h 2 --cap-7d 5 --quiet 21:00-08:00 \
  [--prefs prefs.csv] [--holdout holdout.csv] [--window rolling|calendar] \
  [--classes marketing] [--channels email,sms,push] [--trust-sync-on] \
  [--grace-hours 1] [--latency-fail-hours 48] [--json]
```

- Pass `--cap-24h`, `--cap-7d` and `--quiet` only from the written policy. Without caps the screen applies and over-cap recipients are WARN; without `--quiet`, FC07 is NOT CHECKED.
- `--window calendar` counts the recipient's local calendar day (UTC when the timezone is missing). Use the mode the policy promises; a Braze cap counts calendar days even when the policy says rolling (FC03).
- `--classes` and `--channels` restrict counting to what the policy counts (FC05, FC04, FC07, JC07). Customer.io in-app messages, for example, are outside its limits. PC02, PC03, PC08 and MH03 always read every marketing-class send on every channel.
- `--grace-hours` (default 1) and `--latency-fail-hours` (default 48) set the delay bands for sends after an opt-out ([consent-and-preferences.md](consent-and-preferences.md)).
- The holdout file may carry `assigned_at` and `released_at`; only sends inside each member's membership count toward MH03. Without them the output says so, and the agent checks the earliest flagged send against the assignment date. For an account-level holdout (Acme's holdout_group per workspace), list every profile in each held-out account, keyed like the send log, with the account's assigned_at.
- Keys: the output prints how many preference-log profiles and how many holdout members appear in the send log. When none do, PC02 or MH03 is NOT CHECKED instead of PASS: the files use different keys or salts, or nobody in them received anything. Compare one key in both files, then rerun or rate by hand.
- PC08 reads every SMS or WhatsApp send of any class after a STOP (preference source `sms_keyword`) in the channel or global scope, apart from one confirmation text within 5 minutes (an audit default). Marketing texts after any opt-out count at any delay.
- NOT CHECKED lines join the summary when there are any, for example `1 FAIL, 0 WARN, 3 PASS, 2 NOT CHECKED`.
- Output: the distribution (p50, p90, p99, max) over recipients with at least one counted send, the over-cap recipients by count, the per-sender split for each over-cap recipient, sends after opt-out, suspected stale writes, holdout sends, quiet-hours sends and the top 5 journey pairs within 24 hours.
- Read FC04 from the per-sender split. "N of M were within every sender's own cap" with N above 0 is the cross-sender failure. "0 of M" means one sender went over alone in every case: that sender's cap is missing or too loose (FC02, FC05), and a shared budget would not have been the fix.
- Exit status 1 means a FAIL; 2 means bad input, a file that is not a UTF-8 CSV (save Excel files as CSV UTF-8), an unknown `message_class` or a contact detail in a key column.

## Rules

### FC01 written-policy

- WARN: no policy naming identity, channels, counted classes, window type, limits, exemptions with reasons and blocked-message treatment.

### FC02 config-coverage

- WARN: an optional journey in any sender is not covered by that sender's cap.
- FAIL: the same, when the policy says all optional messages are capped.
- Check per platform: Customer.io limit assignments; Klaviyo Smart Sending per flow message; Iterable Frequency Management status; Braze campaigns and Canvases set not to obey caps; HubSpot frequency limit scope.

### FC03 window-semantics

- WARN: a calendar-day, relative or per-channel window does not match the policy text. A 23:50 send and a 00:10 send fit two calendar days but not one rolling 24 hours.

### FC04 cross-sender-budget

- WARN: several senders send optional messages and there is no shared budget or written scope ("the cap covers Customer.io only").
- FAIL: recipients are over the policy across senders while each sender is within its own cap. message_pressure.py prints "cross-sender: N of M over-cap recipients were within every sender's own cap".

### FC05 observed-pressure

- FAIL: any recipient above a documented cap in a counted class.
- WARN: no policy, and recipients above the screen (2 per rolling 24 hours, 5 per rolling 7 days).
- Report the distribution, not the average: an average of two can hide a few people receiving twelve.

### FC06 blocked-message-behaviour

- WARN: retry or skip behaviour is unknown for a state-sensitive message (setup reminder, payment reminder, expiring offer).
- FAIL: Customer.io auto-retry is on for a state-sensitive message, because retries continue after the person exits; or later Braze copy assumes an earlier capped step arrived; or a Klaviyo sequence whose later message refers to a skipped earlier one.
- Detect: sends after the exit or goal event; cap events on an earlier step for users who received the later step.

### FC07 quiet-hours

- WARN: optional sends inside the documented quiet window in recipient local time, or no fallback for profiles without a timezone (TD08).
- FAIL: any SMS inside the documented quiet window.

### FC08 suppression-sync

- WARN: senders that can reach the same address or number share no suppression sync, and no harm is observed yet.
- FAIL: a later optional send to an address or number that another sender had suppressed after a hard bounce, complaint or unsubscribe.
- Facts: Braze marks a hard-bounced address invalid without changing the subscription state, so "subscribed" does not mean reachable. HubSpot's sending requirements allow marketing email only to contacts who have not opted out of your emails or marked one as spam in another tool (read 2026-11-02), so an opt-out held only in the lifecycle tool still binds HubSpot sends.

```sql
-- FC08: optional sends after the same person was suppressed in another sender
select s.sender, x.sender as suppressed_in, x.reason, count(*) as sends_after_suppression
from sends s
join suppressions x
  on x.profile_key = s.profile_key
 and x.sender <> s.sender
 and s.sent_at > x.suppressed_at
where s.message_class = 'marketing'
  and x.reason in ('hard_bounce', 'complaint', 'unsubscribe')
group by 1, 2, 3;
```

- Confirm: seeds S04 (one-click unsubscribe reaches every sender) and S10 (cap collision across senders).

### FC09 priority

- WARN: task-critical messages (setup, payment, renewal) were blocked by broad campaigns in more than 10% of their candidate sends (an audit default).
- Method: for each task-critical journey, count candidate sends, sends, cap blocks, completed-task exclusions and expired candidates. Report blocks as a share of candidates, and keep the exclusions separate; folding them into "cap failures" overstates the problem.

## Reading blocked-message data

- Braze: frequency cap events in Messaging History; in Currents, an abort event with `abort_type` frequency_capped (sibling skill).
- Customer.io: the message limits review separates messages awaiting a retry from messages marked undeliverable; read the reason, because those statuses cover more than limits.
- Klaviyo: skip reasons in flow and campaign analytics, including Smart Sending.
- HubSpot: workflow action history shows skipped email actions and the reason (verify the labels live).
- Iterable: send skip events with reason `FrequencyCapping`.

Designing a cross-sender cap service or shared queue belongs to preferences-caps-and-suppression (planned). Until it ships, record the gap and the change request; do not design the service inside the audit.
