> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Contact Policies

> Rules that suppress already-qualified Offers based on contact frequency, timing, budget, and cross-channel constraints.

<Note>
  **See also**: [Contact Policies REST API reference](/api-reference/contact-policies) for request/response shapes, status codes, and error semantics.
</Note>

## Overview

**Contact Policies** are post-decisioning-gate rules that remove Offers a customer is eligible for but should not receive right now. The decision-flow engine evaluates them **after** decisioning gates and consent checks, but **before** scoring and ranking. Each policy inspects materialized interaction summaries (impression counts, last-contact timestamps, outcome history) and either blocks or allows a candidate.

Policies are evaluated in **priority order** (highest first). An `allow_override` policy that matches a candidate causes the engine to skip all blocking rules for that candidate. Otherwise, the first blocking rule that fires removes the candidate from the result set.

Mandatory Offers (marked `mandatory` on the Offer) bypass the pre-computed **suppression pre-filter** entirely — a mandatory Offer survives even a global suppression nuke. In the **live** contact-policy evaluation, mandatory Offers are *not* automatically exempt from blocking rules; they are only un-blocked when an `allow_override` policy sets `config.allowMandatory: true`.

***

## Rule Types

KaireonAI supports **14** rule types. All are implemented in the contact-policy engine **and** accepted by the API validation enum (`CreateContactPolicySchema.ruleType` in `api-validate.ts`), including `metric_condition`.

| Rule Type              | Summary                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Scopes                           |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `frequency_cap`        | Max impressions per day / week / month / total. Supports optional campaign filters (`withinCampaignId`, `excludeCampaignIds`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | global, offer, creative, channel |
| `cooldown`             | Minimum hours between contacts                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | global, offer, creative, channel |
| `budget_exhausted`     | Suppress when impression or spend budget consumed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | offer, creative                  |
| `outcome_based`        | Suppress after one or more outcomes (e.g. Adverse Outcomes preset: complaint / unsubscribe / hard\_bounce / spam\_report)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | global, offer, creative          |
| `segment_exclusion`    | Exclude customers in named segments                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | global                           |
| `time_window`          | Restrict to specific hours and days of the week                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | global, channel                  |
| `mutual_exclusion`     | If one Offer in a group was served, suppress the others                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | offer                            |
| `cross_channel_cap`    | Frequency cap aggregated across all channels for one Offer                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | global, offer, creative, channel |
| `customer_total_cap`   | Customer Communication Budget — total contacts a single customer receives across **all** Offers, channels, and creatives in a window                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | global                           |
| `offer_category_cap`   | Cap total contacts a customer receives in a specific `Offer.category` (free-form marketing string like `acquisition`, `retention`) per period                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | global                           |
| `allow_override`       | Explicitly allow contact despite other blocking rules                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | global, offer, creative, channel |
| `category_suppression` | Suppress all Offers in a category after any was shown recently                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | global                           |
| `do_not_contact`       | Blocks every candidate for customers on the DNC list — now also blocks fresh customers with no interaction history. Three working ways to populate the list (see [`do_not_contact`](#do_not_contact) below): explicit `config.customerIds[]`, external `config.dncSource` (enforced by the consent stage, not this engine), or `scope: "customer"` + `scopeId` (engine-only today — not yet creatable via the standard API/UI). **A `do_not_contact` policy with empty config + `scope: "global"` is a no-op** (the DNC list is empty so no one is suppressed). Honors legal/regulatory boundaries (CAN-SPAM, GDPR opt-out). | global, customer (engine-only)   |
| `metric_condition`     | Block when a behavioral metric crosses a threshold                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | global, offer, creative, channel |

<Note>
  All **14** rule types — including `metric_condition`, `do_not_contact`, `customer_total_cap`, `offer_category_cap`, and `category_suppression` — are wired end-to-end: implemented in the contact-policy engine **and** accepted by the API validation enum (`CreateContactPolicySchema.ruleType` in `api-validate.ts`).
</Note>

<Warning>
  **Cooldown / frequency-cap / outcome suppressions are per-offer by default.** When a `cooldown`, `frequency_cap`, or `outcome_based` policy fires in response to an interaction, it suppresses **only the offer that was interacted with** — dismissing one offer does not hide the others. To apply a **customer-wide cool-off** (suppress *all* offers for the window — e.g. "give the customer a break after a complaint"), set `config.applyToAllOffers: true` (or `config.scope: "customer"`) explicitly. This opt-in exists because a customer-wide suppression removes every non-mandatory offer, so it must be deliberate, never the silent result of a global-scoped policy.

  **Policy changes take effect on the next decision.** The active policy set is cached per tenant with a `300s` TTL, but the contact-policy CRUD routes (POST / PUT / DELETE) **invalidate that cache on every write** (`invalidateEntityCache(tenantId, "contactPolicy")`). Qualification-rule CRUD does the same for its cache. The 300-second TTL is only a background-refresh fallback; a saved edit is picked up on the next `/recommend` or `/respond`, not after a 5-minute wait.
</Warning>

### do\_not\_contact

Suppresses every candidate for customers on the DNC list — across every channel and placement in the request. This is the **only** mechanism that suppresses across channels; in-channel coupling stays a per-channel decision.

When a `do_not_contact` policy fires, the blocked candidate's trace entry carries `ruleType: "do_not_contact"` and `reason: "Customer <id> is on the do-not-contact list (policy <id>)"`.

**Three ways to define the DNC list** (pick one):

1. **Explicit list** — `config.customerIds: [...]` on a policy (typically `scope: "global"`). A customer on this list is blocked for every candidate the policy is scoped to. Useful when the DNC list is a small, version-controlled set or you want to seed via API.

   ```json theme={null}
   {
     "name": "Hard DNC",
     "ruleType": "do_not_contact",
     "scope": "global",
     "config": { "customerIds": ["BNK-00007", "BNK-00021"] }
   }
   ```

2. **External source** — `config.dncSource: "internal_dnc"` (or any other source key). The per-candidate contact-policy engine does **not** perform an external suppression-list lookup for this branch — it logs a debug entry and returns not-blocked. The "opted-out customer" intent is instead enforced once per request by the [consent stage](/governance-security/consent-management#enforcement-at-decision-time) in the recommend pipeline (`getConsent` + `hasConsent`), which suppresses candidates for channels whose consent was revoked. Industry-accelerator templates seed this shape by default.

   ```json theme={null}
   {
     "name": "Customer DNC",
     "ruleType": "do_not_contact",
     "scope": "global",
     "config": { "dncSource": "internal_dnc" }
   }
   ```

3. **`scope: "customer"` with `scopeId` set to the customer's id** — a single policy row pinned to one customer, evaluated in `scopeMatches()` by comparing the decision's `customerId` to `policy.scopeId`. This is a narrower alternative to option 1 for a one-off, individually authored opt-out record rather than maintaining a shared `customerIds` array — both mechanisms coexist. `scope: "customer"` requires the caller to have threaded a `customerId` through to the policy engine (see below); without one, the policy never matches (fails closed, not open).

<Warning>
  **`scope: "customer"` is engine-supported but not yet exposed by the standard Contact Policies API or UI.** `CreateContactPolicySchema`/`UpdateContactPolicySchema` (`platform/src/lib/api-validate.ts`) still validate `scope` against `z.enum(["global", "offer", "creative", "channel", "category", "subcategory"])`, and the Contact Policies page has no `Customer` option in its scope selector. A `POST /api/v1/contact-policies` body with `scope: "customer"` is rejected with a 400 today. The mechanism is reachable only via a direct database write (for example, internal provisioning code such as `platform/src/lib/shopify/provision.ts`, `platform/src/lib/ai/recommendation-apply.ts`, or a seed/migration script that calls `prisma.contactPolicy.create()` directly) until the API/UI surface is widened.
</Warning>

**Fresh customers with no interaction history are now reliably blocked.** Previously, `do_not_contact` (both the `customerIds` list and `scope: "customer"`) identified "who is this decision for" by reading `summaries[].customerId` — the interaction-summary rollup rows built from a customer's past contacts. A customer with **no** `InteractionSummary` rows yet (e.g. a brand-new signup who is already on the DNC list) has an empty `summaries` array, so both mechanisms silently failed open for exactly the customers who most needed the block. The recommend/pipeline-runner and batch-executor call sites now thread the decision's `customerId` explicitly into `filterByContactPolicies(...)` regardless of interaction history, so the DNC check no longer depends on the customer having contact history first.

**Empty config + `scope: "global"` is a no-op.** A `do_not_contact` policy with `config: {}` (no `customerIds`, no `dncSource`) represents an empty DNC list — no one is opted out, so no candidate is blocked. The policy can still sit in the flow's `contact_policy` node as a placeholder; operators append `customerIds` (or wire `dncSource`) as opt-outs arrive without re-wiring the flow.

This was BUG-E2E-001 — the prior implementation unconditionally returned `blocked: true` for any `do_not_contact` policy, which silently zeroed every `/recommend` response for tenants that registered the canonical DNC policy. Fixed in `platform/src/lib/contact-policy-engine.ts`; unit-tested in `dnc-policy.test.ts`. The no-history-blocking and `scope: "customer"` wiring above were a follow-up fix (D-01, 2026-07-15 silent-gap audit) to the same file.

***

### frequency\_cap

Limits how many times a customer can be contacted within fixed calendar windows (day / ISO week / month / alltime — not a rolling look-back). You can set one or more caps on the same policy.

**Config fields:**

| Field                | Type                  | Description                                                                                                                                                                                                                                                                                                                                |
| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `maxPerDay`          | `number`              | Max impressions per calendar day                                                                                                                                                                                                                                                                                                           |
| `maxPerWeek`         | `number`              | Max impressions per ISO week                                                                                                                                                                                                                                                                                                               |
| `maxPerMonth`        | `number`              | Max impressions per calendar month                                                                                                                                                                                                                                                                                                         |
| `maxTotal`           | `number`              | Lifetime impression cap                                                                                                                                                                                                                                                                                                                    |
| `withinCampaignId`   | `string` (optional)   | When set, the cap counts only impressions from this campaign run (`Run.id`). Other campaigns and non-campaign sends are ignored for this cap.                                                                                                                                                                                              |
| `excludeCampaignIds` | `string[]` (optional) | When set, impressions from these campaign runs are excluded from the count. Rows with no `campaignId` are still counted.                                                                                                                                                                                                                   |
| `campaignScoped`     | `boolean` (optional)  | Marks the cap as a **per-campaign-run cap**. It only applies while a batch campaign run is evaluating candidates, and then counts only impressions recorded within *that* run. Real-time `/recommend` traffic skips the rule entirely. An explicit `withinCampaignId` (pinning a specific run) takes precedence over the current run's id. |

**Runtime:** The engine aggregates interaction summaries for the matching period and blocks the candidate when `impressions >= max*`. When campaign filters are present, summary rows are filtered before aggregation. The interaction-history fact table and the interaction-summary rollup carry an additive nullable `campaignId` column that the writer populates when the recording call passes one through; existing rows remain `null` and continue to behave the same as before. With `campaignScoped: true`, only rows carrying the currently-executing campaign run's id are counted — legacy inline (non-campaign) batch runs record `campaignId: null`, so a campaign-scoped cap counts nothing there (fail-open).

<Info>
  Batch [campaign runs](/operations-reporting/runs) materialize the same interaction-summary rollup as `/recommend` and `/respond`, so a customer contacted through a batch run counts against these caps on their next contact — batch or real-time — the same way a `/recommend` impression would.
</Info>

```json theme={null}
{
  "ruleType": "frequency_cap",
  "scope": "channel",
  "scopeId": "ch_email",
  "config": {
    "maxPerDay": 1,
    "maxPerWeek": 3,
    "withinCampaignId": "8f2c3a4b-9201-4f6e-8a8c-c0a87de44b21"
  },
  "priority": 80
}
```

#### Preview Impact button (UI)

The `frequency_cap` editor surfaces a **Preview Impact** button that POSTs the
current form to `/api/v1/contact-policies/impact-preview`. The endpoint samples
up to 1,000 active customers, projects how many would have been blocked by the
cap, and returns:

* The percentage of sampled customers that would have been blocked
* Average contacts per customer before and after the policy
* The top suppressed Offers
* Top affected segments (when segments exist)

The preview uses the largest cap on the form (daily → 1 day, weekly → 7,
monthly → 30, total → 365) with `outcomeType: "impression"`. Set at least one
cap field before previewing — the button stays disabled until then.

#### Engagement-aware caps (optional)

Both `frequency_cap` and `customer_total_cap` accept an optional `engagementMultiplier` block that scales the cap based on the customer's engagement health score (range `[0, 1]`).

**Config fields:**

| Field            | Type     | Default | Description                                                                            |
| ---------------- | -------- | ------- | -------------------------------------------------------------------------------------- |
| `lowThreshold`   | `number` | `0.3`   | Scores at or below this trigger `lowMultiplier`                                        |
| `highThreshold`  | `number` | `0.7`   | Scores at or above this trigger `highMultiplier`                                       |
| `lowMultiplier`  | `number` | `0.5`   | Multiplier applied to the cap when score is in the low band (tightens for complainers) |
| `highMultiplier` | `number` | `1.5`   | Multiplier applied to the cap when score is in the high band (widens for engaged)      |

```json theme={null}
{
  "ruleType": "frequency_cap",
  "scope": "global",
  "config": {
    "maxPerWeek": 10,
    "engagementMultiplier": {
      "lowThreshold": 0.3,
      "highThreshold": 0.7,
      "lowMultiplier": 0.5,
      "highMultiplier": 1.5
    }
  },
  "priority": 80
}
```

**Engagement health score formula** (per customer, computed nightly from a 90-day rollup of the interaction-history fact table):

```
score = clip(
  0.5
  + 0.3 * (positiveRate - 0.5)      // baseline tilt by CTR-like
  - 0.4 * complaintRate              // hard penalty (complaint, spam_report)
  - 0.2 * unsubscribeRate            // softer penalty
  + 0.1 * recencyBoost               // contacted recently = engaged
  , 0, 1
)

positiveRate    = (clicks + converts) / max(impressions, 1)
complaintRate   = (complaints + spam_reports) / max(impressions, 1)
unsubscribeRate = unsubscribes / max(impressions, 1)
recencyBoost    = 1 if lastContactAt < 7d, 0.5 if < 30d, 0 otherwise
```

The formula is hardcoded in the engagement-health helper. Tenant-level overrides are not yet supported.

**Enabling the cron** (nightly batch recompute):

```bash theme={null}
curl -X GET https://playground.kaireonai.com/api/v1/cron/engagement-health-recompute \
  -H "Authorization: Bearer $CRON_SECRET"
```

The endpoint iterates every tenant, queries the last 90 days of interaction history, computes per-customer scores, and upserts to `customer_engagement_health`. Per-tenant errors are reported but do not fail the run.

**Limitation — nightly batch, not real-time.** The engagement score reflects the **previous** day's data. A customer who unsubscribed today won't see their cap tightened until the next cron run. The upgrade path is to consume `interaction.recorded.v1` from the [Domain Event Stream](/studio/event-stream) and update the score in real time. When `engagementMultiplier` is omitted from the rule, or when no score has been computed yet for the customer, caps behave identically to the pre-2C-3 behavior (no scaling).

***

### cooldown

Enforces a minimum wait period (in hours) since the last contact before the same Offer can be served again.

**Config fields:**

| Field           | Type     | Description                    |
| --------------- | -------- | ------------------------------ |
| `cooldownHours` | `number` | Minimum hours between contacts |

**Runtime:** Looks up `lastContactAt` from interaction summaries and blocks if fewer than `cooldownHours` have elapsed.

```json theme={null}
{
  "ruleType": "cooldown",
  "scope": "offer",
  "scopeId": "off_welcome_bonus",
  "config": {
    "cooldownHours": 48
  },
  "priority": 60
}
```

***

### budget\_exhausted

Suppresses an Offer when its impression count or spend crosses a threshold. The engine checks alltime summary records.

**Config fields:**

| Field         | Type                       | Description                            |
| ------------- | -------------------------- | -------------------------------------- |
| `budgetField` | `"impressions" \| "spend"` | Which metric to check                  |
| `threshold`   | `number`                   | Value at which the Offer is suppressed |

**Runtime:** Reads the alltime summary for the exact offer + creative + channel combination and blocks when the budget field meets or exceeds `threshold`.

```json theme={null}
{
  "ruleType": "budget_exhausted",
  "scope": "offer",
  "scopeId": "off_spring_promo",
  "config": {
    "budgetField": "impressions",
    "threshold": 50000
  },
  "priority": 90
}
```

***

### outcome\_based

Suppresses an Offer for a specified number of days after a customer records a particular outcome — or any outcome from a configured set. Pick one or many outcome keys; when ANY of them is the most-recent recorded outcome, the candidate is blocked until `suppressForDays` have passed.

**Config fields:**

| Field             | Type                 | Description                                                                                                                                                                                                                                                                                               |
| ----------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `afterOutcome`    | `string \| string[]` | Outcome key(s) that trigger suppression. Pass a single string (legacy) or an array of strings (preferred). The Studio UI exposes a one-click **Adverse Outcomes preset** that fills the array with `["complaint", "unsubscribe", "hard_bounce", "spam_report"]` for the standard compliance quiet-period. |
| `suppressForDays` | `number`             | Number of days to suppress after any of those outcomes. Adverse-outcome standard practice is `90`.                                                                                                                                                                                                        |

**Runtime:** Reads the last recorded outcome key for the customer/scope. If that outcome appears in the `afterOutcome` set, the candidate is blocked until `suppressForDays` have elapsed since `lastContactAt`. A single string is treated as a one-element array — existing rules continue to work unchanged.

**Adverse Outcomes preset (canonical, array form):**

```json theme={null}
{
  "ruleType": "outcome_based",
  "scope": "global",
  "config": {
    "afterOutcome": ["complaint", "unsubscribe", "hard_bounce", "spam_report"],
    "suppressForDays": 90
  },
  "priority": 95
}
```

**Single-outcome (backward-compatible string form):**

```json theme={null}
{
  "ruleType": "outcome_based",
  "scope": "offer",
  "scopeId": "off_credit_card",
  "config": {
    "afterOutcome": "complaint",
    "suppressForDays": 90
  },
  "priority": 85
}
```

***

### segment\_exclusion

Blocks all Offers for customers belonging to one or more excluded segments. This is a global-only rule.

**Config fields:**

| Field             | Type       | Description             |
| ----------------- | ---------- | ----------------------- |
| `excludeSegments` | `string[]` | Segment keys to exclude |

**Runtime:** Compares segments from the Recommend request against `excludeSegments`. If the customer matches any, the candidate is blocked. If no segment data is available in the request, the engine **fails open** (allows the candidate through) to avoid blocking all offers when segment data is unavailable.

```json theme={null}
{
  "ruleType": "segment_exclusion",
  "scope": "global",
  "config": {
    "excludeSegments": ["do_not_contact", "legal_hold", "recently_churned"]
  },
  "priority": 100
}
```

***

### time\_window

Restricts contacts to specific hours and/or days of the week. Supports IANA timezone strings validated at write time.

**Config fields:**

| Field        | Type            | Description                                                                      |
| ------------ | --------------- | -------------------------------------------------------------------------------- |
| `startHour`  | `number` (0-23) | Start of allowed window (inclusive)                                              |
| `endHour`    | `number` (0-23) | End of allowed window (exclusive)                                                |
| `daysOfWeek` | `string[]`      | Allowed days: `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`, `Sun`                    |
| `timezone`   | `string`        | IANA timezone (e.g. `America/New_York`). Falls back to UTC if omitted or invalid |

**Runtime:** Converts the current time to the configured timezone, then checks both day-of-week and hour range. Overnight windows (e.g. `startHour: 22`, `endHour: 6`) are handled correctly.

```json theme={null}
{
  "ruleType": "time_window",
  "scope": "channel",
  "scopeId": "ch_sms",
  "config": {
    "daysOfWeek": ["Mon", "Tue", "Wed", "Thu", "Fri"],
    "startHour": 9,
    "endHour": 18,
    "timezone": "America/New_York"
  },
  "priority": 70
}
```

***

### mutual\_exclusion

Prevents competing Offers from being served to the same customer within a time window. If any Offer in the group has been shown recently, the others are suppressed.

**Config fields:**

| Field             | Type       | Description                                                           |
| ----------------- | ---------- | --------------------------------------------------------------------- |
| `offerGroup`      | `string[]` | List of Offer IDs that are mutually exclusive                         |
| `suppressForDays` | `number`   | Days to suppress other group members after one is shown (default: 90) |

**Runtime:** For each candidate Offer in the group, checks whether any other Offer in the group has an alltime summary with impressions > 0 and `lastContactAt` within `suppressForDays`.

```json theme={null}
{
  "ruleType": "mutual_exclusion",
  "scope": "offer",
  "scopeId": "off_platinum_card",
  "config": {
    "offerGroup": ["off_platinum_card", "off_gold_card", "off_silver_card"],
    "suppressForDays": 30
  },
  "priority": 75
}
```

***

### category\_suppression

Suppresses all Offers in a category (or sub-category) for a specified number of days after **any** Offer in that category was shown to the customer. This prevents fatigue from repeated pitches in the same product area.

**Config fields:**

| Field             | Type                | Description                                                      |
| ----------------- | ------------------- | ---------------------------------------------------------------- |
| `categoryId`      | `string`            | The Category ID to suppress                                      |
| `subCategoryId`   | `string` (optional) | Narrow to a specific sub-category                                |
| `suppressionDays` | `number`            | Days to suppress after any category Offer was shown (default: 7) |

**Runtime:** Builds a map of all Offer IDs in the target category from the current candidate set. Checks alltime summaries for any of those Offers. If any was shown within `suppressionDays`, all candidates in that category are blocked.

```json theme={null}
{
  "ruleType": "category_suppression",
  "scope": "global",
  "config": {
    "categoryId": "cat_auto_insurance",
    "suppressionDays": 7
  },
  "priority": 80
}
```

***

### cross\_channel\_cap

Like `frequency_cap`, but aggregates impressions **across all channels** for the same Offer within a period.

**Config fields:**

| Field           | Type                               | Description                                                                                                                                                                |
| --------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `periodType`    | `"daily" \| "weekly" \| "monthly"` | Aggregation period (default: `daily`)                                                                                                                                      |
| `maxTotal`      | `number`                           | Max total impressions across all channels in the period                                                                                                                    |
| `appliesAcross` | `string[]` (optional)              | Channel-id allow-list. When provided + non-empty, only summaries whose `channelId` is in the list count toward the cap. Omitted / empty = legacy behaviour (all channels). |

**Runtime:** Sums impressions across channel summaries for the Offer in the current period and blocks when the total meets or exceeds `maxTotal`. When `appliesAcross` is set, only listed channels contribute to the sum — useful for rules like "max 3 contacts/day across email OR sms but push is unlimited."

```json theme={null}
{
  "ruleType": "cross_channel_cap",
  "scope": "global",
  "config": {
    "periodType": "daily",
    "maxTotal": 3,
    "appliesAcross": ["email", "sms"]
  },
  "priority": 80
}
```

The reject reason emitted on a block surfaces the scope: `Cross-channel cap reached across [email, sms]: 3/3 impressions` so the operator can distinguish a global cap hit from a scoped one in the audit trail.

***

### customer\_total\_cap

The **Customer Communication Budget**. Caps the total number of contacts a single customer can receive across **every Offer, channel, and creative** in a rolling period. Use this for compliance ceilings or customer-experience guardrails where the absolute number of marketing touches matters more than which Offer was sent.

**How it differs from `cross_channel_cap`:**

| Rule                 | What it caps                                               | Typical use                                                                                    |
| -------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `cross_channel_cap`  | Total impressions of **one Offer** across all channels     | "Don't send the same Mortgage offer more than 5 times this week, regardless of email/SMS/push" |
| `customer_total_cap` | Total contacts of **all Offers** combined for one customer | "Don't send a customer more than 10 marketing messages this week, period"                      |

**Config fields:**

| Field        | Type                                            | Description                                                                    |
| ------------ | ----------------------------------------------- | ------------------------------------------------------------------------------ |
| `maxTotal`   | `number` (>= 0)                                 | Total number of contacts the customer can receive in the period. **Required.** |
| `periodType` | `"daily" \| "weekly" \| "monthly" \| "alltime"` | Reset window. Defaults to `weekly`.                                            |

**Runtime:** The engine sums impressions across **every** summary row for the customer in the matching `periodType` + `periodKey` — no offer / creative / channel filter is applied. Blocks the candidate when `totalImpressions >= maxTotal`. Period boundaries follow the same convention as `frequency_cap`: daily resets at midnight UTC, weekly at Monday 00:00 UTC ISO week, monthly on the first.

```json theme={null}
{
  "name": "Weekly Customer Communication Budget",
  "ruleType": "customer_total_cap",
  "scope": "global",
  "config": {
    "maxTotal": 10,
    "periodType": "weekly"
  },
  "priority": 90
}
```

<Note>
  `customer_total_cap` is global by design — it caps the sum of contacts to a single customer regardless of which Offer is being scored. Setting a non-global scope on this rule type has no useful effect.
</Note>

***

### offer\_category\_cap

Caps the number of contacts a customer can receive in a specific
**`Offer.category`** (the free-form marketing classification string like
`acquisition`, `retention`, or `engagement`) inside a rolling window. The cap
only applies to candidates whose `Offer.category` matches `targetCategory` —
candidates in other categories pass through unaffected.

This is distinct from `category_suppression`, which uses `Offer.categoryId`
(the FK to the `Category` model). `offer_category_cap` uses the free-form
string axis instead, so you can cap acquisition vs retention messaging
independently of the category taxonomy.

**Config fields:**

| Field            | Type                                            | Description                                                                           |
| ---------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------- |
| `targetCategory` | `string`                                        | The `Offer.category` value this cap applies to (e.g. `acquisition`). **Required.**    |
| `maxTotal`       | `number` (>= 0)                                 | Total contacts in this category the customer can receive in the period. **Required.** |
| `periodType`     | `"daily" \| "weekly" \| "monthly" \| "alltime"` | Reset window. Defaults to `weekly`.                                                   |

**Runtime:** The gate matches `targetCategory` **case-insensitively** against the
candidate's `Offer.category` marketing string. (For robustness it also matches the
candidate's `categoryId`, so a rule that stored the taxonomy id in `targetCategory`
still gates — but the intended axis is the free-form string.) When the candidate is
in the target category, the engine filters interaction summaries to the period
window and to rows whose denormalized `offerCategory` matches `targetCategory`, then
blocks the candidate when `totalImpressions >= maxTotal`.

<Note>
  The interaction-summary rollup denormalizes `Offer.category` onto each summary row
  (fix #155), so the cap counts **only** contacts in the target category. Summary
  rows written before that denormalization landed carry a `NULL` category and are
  intentionally **not** counted (the engine prefers under-counting to the earlier
  over-count). Backfill legacy `NULL` rows if you need them included.
</Note>

```json theme={null}
{
  "name": "Weekly Acquisition Cap",
  "ruleType": "offer_category_cap",
  "scope": "global",
  "config": {
    "targetCategory": "acquisition",
    "maxTotal": 3,
    "periodType": "weekly"
  },
  "priority": 85
}
```

***

### allow\_override

An override that **allows** contact despite other blocking policies. When an `allow_override` policy **matches** a candidate, the engine skips all blocking rules for that candidate. Use this for mandatory or time-sensitive Offers that must bypass normal frequency limits.

**Config fields (all optional):**

| Field            | Type       | Description                                                                                                             |
| ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
| `allowMandatory` | `boolean`  | Un-block **mandatory** Offers only. A candidate matches when `allowMandatory` is `true` **and** the Offer is mandatory. |
| `allowSegments`  | `string[]` | Un-block only when the customer is in one of these segments.                                                            |
| `allowOfferIds`  | `string[]` | Un-block only these specific Offer IDs.                                                                                 |

**Matching rules** — evaluated **before** all blocking rules:

1. A mandatory Offer matches immediately when `allowMandatory: true`.
2. When `allowSegments` is set, the customer must be in one of the listed segments; when `allowOfferIds` is set, the Offer must be in the list. If both are set, **both** must hold.
3. An override whose **only** field is `allowMandatory: true` applies to mandatory Offers **only** — it does **not** un-block non-mandatory candidates.
4. An override with **no conditions at all** (`{}`) is a **blanket** override — it matches every candidate. Scope it (via the policy's `scope`/`scopeId`) or add a condition unless you truly mean "bypass contact policy for everyone."

The engine logs a warning for every override match to maintain an audit trail.

<Warning>
  A July 2026 fix corrected a bug where an override with only `allowMandatory: true` un-blocked **every** candidate (not just mandatory Offers). `allowMandatory` alone now un-blocks mandatory Offers only. Add `allowSegments`/`allowOfferIds`, or leave the config empty for an intentional blanket override.
</Warning>

<Warning>
  Use `allow_override` sparingly. Every override is logged with a warning. Consider making overrides time-bounded by pausing or archiving them after the campaign ends.
</Warning>

```json theme={null}
{
  "ruleType": "allow_override",
  "scope": "offer",
  "scopeId": "off_regulatory_notice",
  "config": {
    "allowOfferIds": ["off_regulatory_notice"]
  },
  "priority": 100
}
```

***

### metric\_condition

Blocks candidates when a [behavioral metric](/studio/behavioral-metrics) value crosses a threshold. Supports dimension mapping to resolve metric values per candidate.

<Note>
  `metric_condition` is included in the API validation enum (`CreateContactPolicySchema.ruleType` in `api-validate.ts`) and is evaluated live in the contact-policy engine. At decision time the pipeline runner loads the customer's behavioral `metricValue` rows so `metric_condition` policies can block; when no matching metric value is found the rule evaluates to not-blocked.
</Note>

**Config fields:**

| Field              | Type                                     | Description                                                                             |
| ------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `metricId`         | `string`                                 | The behavioral metric to evaluate                                                       |
| `operator`         | `"gt" \| "gte" \| "lt" \| "lte" \| "eq"` | Comparison operator (default: `gte`)                                                    |
| `threshold`        | `number`                                 | Value that triggers suppression                                                         |
| `dimensionMapping` | `Record<string, string>`                 | Maps metric dimensions to candidate fields (e.g. `{"channel": "$candidate.channelId"}`) |

**Runtime:** Looks up the metric value using the dimension mapping, applies the operator, and blocks if the condition is met.

```json theme={null}
{
  "ruleType": "metric_condition",
  "scope": "global",
  "config": {
    "metricId": "met_7d_impression_count",
    "operator": "gte",
    "threshold": 10,
    "dimensionMapping": {
      "channel": "$candidate.channelId"
    }
  },
  "priority": 70
}
```

***

## Frequency Caps

**Frequency Caps** are system-wide caps that limit the total number of times an offer, category, or channel can be recommended across all customers within a time period. Unlike contact-policy caps (which are per-customer), frequency caps enforce business-level limits such as "no more than 20,000 email impressions per week" or "limit the Gold Card offer to 5,000 recommendations per month."

### How They Differ from Contact Policies

|                       | Contact Policies                              | Frequency Caps                                                                      |
| --------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Scope**             | Per-customer                                  | System-wide (all customers)                                                         |
| **Question answered** | "Has this customer been contacted too often?" | "Has this offer/channel been used too much overall?"                                |
| **Evaluation point**  | After qualification, before scoring           | During the Contact Policy stage — after per-customer policies, still before scoring |
| **Typical use**       | Per-customer cooldowns, mutual exclusion      | Inventory caps, channel throughput limits, campaign budgets                         |

### Configuration

Each frequency cap specifies a **scope**, **period**, and **maximum count**:

| Field       | Type                                             | Required | Description                                     |
| ----------- | ------------------------------------------------ | -------- | ----------------------------------------------- |
| `name`      | string                                           | Yes      | Human-readable name                             |
| `scope`     | `"global" \| "offer" \| "category" \| "channel"` | Yes      | What entity the cap applies to                  |
| `scopeId`   | string                                           | No       | The ID of the scoped entity (null for `global`) |
| `period`    | `"daily" \| "weekly" \| "monthly"`               | Yes      | Rolling time window                             |
| `maxVolume` | integer (>= 1)                                   | Yes      | Maximum deliveries allowed in the period        |
| `enabled`   | boolean                                          | No       | Default `true`; only enabled caps are evaluated |

The server also maintains `currentCount` (auto-incremented) and `resetAt` (when the counter resets) on each cap — these are computed, not supplied on create.

**Example:** Limit the "Premium Card" offer to 10,000 deliveries per week:

```json theme={null}
{
  "name": "Premium Card Weekly Cap",
  "scope": "offer",
  "scopeId": "offer_premium_card",
  "period": "weekly",
  "maxVolume": 10000,
  "enabled": true
}
```

### Counter Mechanism

Frequency-cap counters are auto-incremented when **impression** outcomes are recorded via the Respond API. This means:

* The counter reflects actual deliveries, not just recommendations
* A recommendation that is never delivered does not consume volume
* Counters reset automatically at the start of each period (midnight UTC for daily, Monday 00:00 UTC for weekly, first of month for monthly)

### API

| Method | Endpoint                         | Description                                      |
| ------ | -------------------------------- | ------------------------------------------------ |
| GET    | `/api/v1/frequency-caps`         | List all frequency caps                          |
| POST   | `/api/v1/frequency-caps`         | Create a new frequency cap                       |
| PUT    | `/api/v1/frequency-caps`         | Update an existing frequency cap (requires `id`) |
| DELETE | `/api/v1/frequency-caps?id={id}` | Delete a frequency cap                           |

<Tip>
  Use frequency caps alongside contact policies for complete control. Contact policies protect individual customers from over-contact; frequency caps protect your business from over-committing inventory or exceeding channel capacity.
</Tip>

***

## Scopes

The API accepts **six** scope values on a Contact Policy, which determine which candidates it applies to.

| Scope         | Matches When                                           | Typical Use                           |
| ------------- | ------------------------------------------------------ | ------------------------------------- |
| `global`      | Always matches every candidate                         | Company-wide compliance rules         |
| `offer`       | `scopeId` equals the candidate's Offer ID              | Product-specific frequency limits     |
| `creative`    | `scopeId` equals the candidate's Creative ID           | Creative-level fatigue rules          |
| `channel`     | `scopeId` equals the candidate's Channel ID            | Channel-specific time windows or caps |
| `category`    | `scopeId` equals the candidate's Offer category ID     | Product-area-wide caps                |
| `subcategory` | `scopeId` equals the candidate's Offer sub-category ID | Narrower product-area caps            |

The engine's `scopeMatches` additionally recognizes `placement` (matches the candidate's placement ID) and `segment` (defers to the rule body, which inspects the request's segments) at runtime, but those two are not currently part of the API scope enum. Any unrecognized scope is treated as **not applicable** (the policy is skipped for that candidate). Not every rule type is meaningful at every scope; the UI filters the scope dropdown based on the selected rule type.

<Note>
  A separate, narrower `ContactPolicyScopeSchema` in `domain/studio.ts` still enumerates only `{ global, channel, offer }`. That type is not what validates the create/update API — `CreateContactPolicySchema` in `api-validate.ts` is, and it accepts the six scopes above. The `domain/studio.ts` enum is stale relative to the runtime and the API.
</Note>

<Tip>
  Use `global` scope for company-wide compliance rules (e.g. "no more than 5 contacts per week across all channels") and narrower scopes for product-specific constraints.
</Tip>

***

## Priority and Conflict Resolution

Each Contact Policy has a **priority** value from 0 to 100 (default: 50). Higher values are evaluated first.

The engine resolves conflicts as follows:

1. **Sort** all active policies by priority descending.
2. **Separate** `allow_override` policies from blocking policies.
3. **For each candidate**, check `allow_override` policies first. If any matches, the candidate is explicitly allowed and all blocking rules are skipped.
4. **Otherwise**, evaluate blocking policies in priority order. The first rule that blocks removes the candidate.
5. **Unknown rule types** fail **closed** — the candidate is **blocked** ("`Unknown rule type: "…" — blocked for safety`") and the engine logs `error` in production (`warn` otherwise). A misconfigured or corrupted `ruleType` suppresses the candidate rather than letting it slip through; watch the logs for these entries.

This means a priority-100 `allow_override` will beat a priority-100 `frequency_cap` because overrides are always checked first regardless of priority.

***

## Suppressions (Pre-Computed Enforcement)

For the six pre-computable rule types, contact policies are enforced via **pre-computed suppression records** as a fast first pass, *in addition to* the live evaluation that still runs afterward. This pre-filter reduces work in the Recommend API by removing already-suppressed candidates with a single database read before the live contact-policy engine evaluates the remaining policies.

### How It Works

1. **Write path (Respond API):** When a `respond` call records an outcome that triggers a policy threshold (e.g., a frequency cap is reached, a cooldown begins), the engine **writes a suppression record** to the database with an expiry timestamp and a scope derived from the policy (see *Scope-on-Write* below).
2. **Read path (Recommend API):** At decision time, the engine loads all active (non-expired) suppressions for the customer in **one query**. Any candidate matching a suppression is immediately removed — no per-policy evaluation needed.

### Scope-on-Write

A materialized suppression **never exceeds the scope of the policy that wrote it**, and a policy only fires for interactions **within** its own scope:

| Policy scope               | Fires on                                | Suppression written (default)                                                                     | With `applyToAllOffers` / `config.scope: "customer"`             |
| -------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| global / unpinned          | any interaction                         | `offer` — the interacted offer only                                                               | `global` — customer-wide                                         |
| `channel`                  | interactions on that channel only       | `offer_channel` — the interacted offer **on that channel only** (`scopeId` = `offerId:channelId`) | `channel` — all offers on that channel; other channels untouched |
| `offer`                    | interactions with that offer only       | `offer` — that offer (the opt-in cannot widen past the policy's offer)                            | same                                                             |
| `category` / `subcategory` | interactions in that (sub)category only | `offer` — the interacted offer                                                                    | `category` — that (sub)category                                  |
| `customer`                 | that customer only                      | `offer` — the interacted offer                                                                    | `global` for that customer                                       |
| `creative` / `placement`   | never materialized                      | — (live evaluation only)                                                                          | —                                                                |

Channel-scoped policies also **count only that channel's contacts** when checking `frequency_cap` / `budget_exhausted` thresholds, mirroring the live engine's per-(offer, channel) aggregation. Combinations a single suppression record cannot express (e.g., a channel-scoped `category_suppression`) are not materialized at all — the live contact-policy evaluation, which always runs after the pre-filter, still enforces them. An under-scoped write only costs a fast-path miss; the engine never writes a broader suppression than the policy allows (previously an email-channel-scoped policy could suppress a customer across *all* channels).

This means the cost of contact policy enforcement at decision time is constant regardless of how many policies are configured.

### Pre-Computed vs Live-Evaluated Policy Types

Not all policy types can be pre-computed. Policies that depend on the current request context (time of day, customer segments in the request payload) must still evaluate live.

| Enforcement                                   | Policy Types                                                                                                                                                |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Pre-computed** (suppression records)        | `cooldown`, `outcome_based`, `mutual_exclusion`, `category_suppression`, `frequency_cap`, `budget_exhausted`                                                |
| **Live-evaluated** (checked at decision time) | `time_window`, `segment_exclusion`, `allow_override`, `cross_channel_cap`, `offer_category_cap`, `customer_total_cap`, `metric_condition`, `do_not_contact` |

<Tip>
  Only the **six** rule types in `PRECOMPUTABLE_RULES` (`suppression-engine.ts`) get suppression records written on the `respond` write path. Note that `cross_channel_cap` is **not** pre-computed — it is evaluated live. The live-evaluated types are request-dependent or span multiple channels/offers, so they are re-checked at decision time. In practice the suppression pre-filter runs first, then the live contact-policy engine still evaluates every loaded policy against the surviving candidates.
</Tip>

### Suppression Expiry Modes

Each suppression record carries an `expiresAt` timestamp. The engine supports three expiry calculation modes:

| Mode           | Behavior                           | Example                                                                        |
| -------------- | ---------------------------------- | ------------------------------------------------------------------------------ |
| `exact`        | Expires exactly N hours from now   | A 48-hour cooldown expires at the same time of day, two days later             |
| `calendar_day` | Expires at midnight (UTC) on day N | A 7-day suppression created on Monday expires at midnight the following Monday |
| `end_of_day`   | Expires at midnight (UTC) tonight  | A daily frequency cap resets at the start of the next calendar day             |

### Audit Trail

Every suppression record embeds **evidence** — a JSON object containing the policy ID, rule type, the threshold that was crossed, and the interaction that triggered it. This evidence is preserved for the lifetime of the suppression and is surfaced in decision traces when debug mode is enabled.

```json theme={null}
{
  "customerId": "C-4821",
  "scope": "channel",
  "scopeId": "ch_email",
  "reason": "frequency_cap",
  "policyId": "cp_email_weekly",
  "expiresAt": "2026-03-30T00:00:00Z",
  "expiryMode": "end_of_day",
  "evidence": {
    "ruleType": "frequency_cap",
    "threshold": { "maxPerWeek": 3 },
    "actual": 3,
    "triggeredBy": "interaction_abc123"
  }
}
```

### Escalating Suppressions

When a customer repeatedly triggers the same policy, the suppression duration escalates automatically. Each suppression record tracks a `triggerCount` that increments on each re-trigger, and the engine looks up the matching escalation tier to determine how long the next suppression lasts.

**Configuration:** Add an `escalation` array to any suppression-eligible policy's config alongside the base `cooldownHours`:

```json theme={null}
{
  "cooldownHours": 48,
  "escalation": [
    { "trigger": 2, "durationDays": 14 },
    { "trigger": 3, "durationDays": 90 },
    { "trigger": 4, "action": "permanent" }
  ]
}
```

**How it works:**

| Trigger Count     | Behavior                                                                    |
| ----------------- | --------------------------------------------------------------------------- |
| 1 (first trigger) | Base suppression duration applies (e.g. `cooldownHours: 48`)                |
| 2                 | Matches `trigger: 2` tier — suppressed for 14 days                          |
| 3                 | Matches `trigger: 3` tier — suppressed for 90 days                          |
| 4+                | Matches `trigger: 4` with `"action": "permanent"` — suppressed for 10 years |

**Reset behavior:** If the customer does not re-trigger the policy within **7 days** of the current suppression's expiry, the `triggerCount` resets to zero. The next violation starts fresh at tier 1.

<Tip>
  Use escalating suppressions for policies like `outcome_based` (e.g. complaints) or `cooldown` where repeat offenders should face progressively longer quiet periods. The `"permanent"` action is implemented as a 10-year suppression to avoid infinite timestamps.
</Tip>

***

## Field Reference

All fields accepted by the `POST /api/v1/contact-policies` endpoint:

| Field         | Type                                                                            | Required | Default    | Description                                                     |
| ------------- | ------------------------------------------------------------------------------- | -------- | ---------- | --------------------------------------------------------------- |
| `name`        | `string`                                                                        | Yes      | --         | Unique policy name                                              |
| `description` | `string`                                                                        | No       | `""`       | Human-readable description                                      |
| `status`      | `"draft" \| "active" \| "paused" \| "archived"`                                 | No       | `"active"` | Only `active` policies are evaluated at decision time           |
| `scope`       | `"global" \| "offer" \| "creative" \| "channel" \| "category" \| "subcategory"` | No       | `"offer"`  | Which entity level this policy targets                          |
| `scopeId`     | `string \| null`                                                                | No       | `null`     | The ID of the scoped entity (required unless scope is `global`) |
| `ruleType`    | enum (see [Rule Types](#rule-types))                                            | Yes      | --         | One of the 14 API-supported rule types                          |
| `config`      | `object`                                                                        | No       | `{}`       | Rule-specific configuration (see each rule type above)          |
| `priority`    | `integer` (0-100)                                                               | No       | `50`       | Evaluation order; higher = evaluated first                      |

***

## Worked Example

### Setup

Customer **C-4821** has already received **3 emails this week** for the "Spring Promo" Offer. A `frequency_cap` policy limits the email channel to 3 per week.

**Policy:**

```json theme={null}
{
  "id": "cp_email_weekly",
  "name": "Weekly Email Cap",
  "ruleType": "frequency_cap",
  "scope": "channel",
  "scopeId": "ch_email",
  "config": { "maxPerWeek": 3 },
  "priority": 80,
  "status": "active"
}
```

### Step 1 -- Frequency Cap Blocks

When the Recommend API runs for customer C-4821, the engine:

1. Loads the weekly interaction summary: `impressions = 3` for `ch_email` in the current ISO week.
2. Evaluates `frequency_cap`: `3 >= 3` (maxPerWeek) -- **BLOCKED**.
3. The candidate is removed from the result set.

**Decision trace (debug mode):**

```json theme={null}
{
  "contactPolicyReasons": [
    {
      "offerId": "off_spring_promo",
      "creativeId": "cr_spring_email_v2",
      "reason": "Weekly frequency cap reached: 3/3",
      "policyId": "cp_email_weekly"
    }
  ]
}
```

### Step 2 -- Allow Override Bypasses the Cap

Now suppose a regulatory notice must reach C-4821 regardless of frequency limits. An `allow_override` policy exists:

```json theme={null}
{
  "id": "cp_regulatory_override",
  "name": "Regulatory Notice Override",
  "ruleType": "allow_override",
  "scope": "offer",
  "scopeId": "off_regulatory_notice",
  "config": {
    "allowOfferIds": ["off_regulatory_notice"]
  },
  "priority": 100,
  "status": "active"
}
```

The engine evaluates `allow_override` policies **before** blocking rules. Because this override matches the regulatory notice Offer, the `frequency_cap` is never checked for that candidate. The regulatory notice is delivered.

The engine logs a warning:

```
WARN [contact-policy-engine] allow_override policy bypassing contact policy
  — ensure this override is time-bounded and approved
  { policyId: "cp_regulatory_override", offerId: "off_regulatory_notice" }
```

***

## API Quick Reference

| Method | Endpoint                           | Auth                  | Description                                        |
| ------ | ---------------------------------- | --------------------- | -------------------------------------------------- |
| GET    | `/api/v1/contact-policies`         | viewer, editor, admin | List policies (paginated, sorted by priority desc) |
| POST   | `/api/v1/contact-policies`         | admin                 | Create a new policy                                |
| PUT    | `/api/v1/contact-policies`         | admin                 | Update an existing policy (requires `id` in body)  |
| DELETE | `/api/v1/contact-policies?id={id}` | admin                 | Soft-delete a policy                               |

Deleting a policy uses **soft-delete** (the record is retained with a `deletedAt` timestamp). If the policy is referenced by any Decision Flow's `draftConfig`, the response includes a `warnings` array listing affected flows (ghost reference check). Updates use `auditedUpdate` to create audit snapshots and increment `rowVersion`.

See the [API Reference](/api-reference) for full request and response schemas.

***

## Contact Policies vs Frequency Caps

Contact policies and [frequency caps](/decisioning/frequency-caps) are complementary mechanisms that are both enforced during the Contact Policy pipeline stage, but they protect different things:

|              | Contact Policies                     | Frequency Caps                                |
| ------------ | ------------------------------------ | --------------------------------------------- |
| **Protects** | Individual customers                 | The business                                  |
| **Purpose**  | Per-customer cooldowns, suppressions | Budget caps, inventory limits, channel quotas |
| **Scope**    | Per-customer interaction history     | System-wide counters across all customers     |

**Evaluation order:** Contact policies are evaluated first, then frequency caps filter the remaining candidates. A customer may pass all contact policy checks but still be blocked by a frequency cap if the offer, category, or channel has reached its delivery cap.

<Tip>
  Use both together for complete control. Contact policies prevent individual customer fatigue; frequency caps prevent over-committing inventory or exceeding channel capacity. See the [Frequency Caps](/decisioning/frequency-caps) page for configuration details.
</Tip>

***

## Effective Rules — see what applies to an offer

A contact policy can be assigned at any of four scope levels — `global`, `category`, `subcategory`, or `offer` — and operators often need to debug which policies actually apply to a specific offer without running a recommendation. The **Effective Rules** view answers that question directly.

Open any offer in `/studio/actions`, click into the detail view, and click **Effective Rules** in the top action bar. The page lists every active contact policy (and decisioning rule) that applies to the offer via the scope hierarchy:

```
global → category → subcategory → offer
```

Each row is annotated with the matched scope so you can see **why** the rule applies (for example, "applies because the offer belongs to category X"). Channel and creative scopes are intentionally excluded — those evaluate at decision time against a specific delivery channel/creative and are visible via [Decision Traces](/api-reference/decision-traces).

The same data is available programmatically:

```http theme={null}
GET /api/v1/offers/:id/effective-rules
```

The endpoint requires any of the `admin`, `editor`, or `viewer` roles and returns both contact policies and decisioning gates sorted by priority descending. See [Decisioning Gates](/decisioning/qualification-rules#effective-rules--inheritance-view-per-offer) for the full response shape.

***

## Related

<CardGroup cols={3}>
  <Card title="Decisioning Gates" icon="filter" href="/decisioning/qualification-rules">
    Rules evaluated before Contact Policies that determine initial Offer eligibility.
  </Card>

  <Card title="Frequency Caps" icon="gauge" href="/decisioning/frequency-caps">
    System-wide delivery caps on offers, categories, and channels.
  </Card>

  <Card title="Behavioral Metrics" icon="chart-bar" href="/studio/behavioral-metrics">
    Create metrics from interaction data to drive metric\_condition policies.
  </Card>

  <Card title="Decision Flows" icon="sitemap" href="/decisioning/decision-flows">
    The pipeline that orchestrates qualification, contact policies, scoring, and ranking.
  </Card>
</CardGroup>
