> ## 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.

# Frequency caps

> Cap per-day, per-week, per-category, per-channel. Each scope produces a different trace signature. Pick the right one for your governance need, then verify.

## What this solves

Operators consistently want two related-but-different things:

1. **"Don't spam this customer."** A global per-day cap on total touches.
2. **"Don't fatigue this category."** A per-category cap so Premium Cards doesn't dominate every email.

The contact-policy engine handles both via the `frequency_cap` ruleType, but the **scope** is what matters — global, category, subcategory, offer, channel, segment, or placement. Each scope filters a different slice of the alltime interaction summary.

## The pattern

A contact policy is `{ruleType, scope, scopeId?, config}`. For frequency\_cap, `config` carries:

* `maxPerDay` / `maxPerWeek` / `maxPerMonth` (any subset; missing = no cap on that window)
* `lookbackHours` (optional, defaults to the window above)

The engine consults `interaction_summaries` filtered by `(customerId, scope, scopeId, period)` and removes any candidate whose count would exceed the cap.

## Recipe 1 — Global per-day cap (3 touches/day, all channels, all categories)

```bash theme={null}
curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "Global 3/day",
    "ruleType": "frequency_cap",
    "config": { "maxPerDay": 3 },
    "scopes": [{ "scope": "global", "scopeId": null }],
    "status": "active"
  }'
```

After three accepted respond events from any customer-offer combination, the next recommend will filter every candidate. Verify: `trace.contactPolicyResults[*].reason` will read `"frequency_cap exceeded: 3/day at scope=global"`.

## Recipe 2 — Per-category cap (Cards category 5/week)

```bash theme={null}
curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "Cards 5/week",
    "ruleType": "frequency_cap",
    "config": { "maxPerWeek": 5 },
    "scopes": [{ "scope": "category", "scopeId": "<cards-category-id>" }],
    "status": "active"
  }'
```

Now only candidates with `offer.categoryId === <cards-category-id>` count against the cap. Loans offers are unaffected. The denormalized `interaction_summaries.offerCategory` column (added in fix #155) makes this query fast.

## Recipe 3 — Per-channel cap (Email 2/day, push unlimited)

```bash theme={null}
curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "Email 2/day",
    "ruleType": "frequency_cap",
    "config": { "maxPerDay": 2 },
    "scopes": [{ "scope": "channel", "scopeId": "<email-channel-id>" }],
    "status": "active"
  }'
```

Combine multiple per-channel caps to express "no more than 2 emails, 1 push, 0 SMS per day."

## Recipe 4 — Per-offer cooldown (don't show the same offer twice in 24h)

This is a different ruleType — `cooldown`, not `frequency_cap`:

```bash theme={null}
curl -X POST https://playground.kaireonai.com/api/v1/contact-policies \
  -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" \
  -d '{
    "name": "24h cooldown",
    "ruleType": "cooldown",
    "config": { "cooldownHours": 24 },
    "scopes": [{ "scope": "offer", "scopeId": "<platinum-card-id>" }],
    "status": "active"
  }'
```

The engine reads the timestamp of the last impression for that (customer, offer) pair and suppresses if within `cooldownHours`.

## What the trace will show

```
contactPolicyResults[
  {
    "policyId": "<id>",
    "ruleType": "frequency_cap",
    "scope": "category",
    "scopeId": "<cards-id>",
    "offerId": "<premium-card-id>",
    "decision": "block",
    "reason": "frequency_cap exceeded: customer hit 5/week limit on category <cards-id>"
  }
]
```

## Gotchas

* **Scope mismatches silently pass.** If a frequency\_cap rule has `scope: "category"` but `scopeId: null`, it applies globally — not what you want. Validate scopeId is set when scope is anything other than `"global"`.
* **`metric_condition` is a different beast.** That ruleType reads a Compute node's output (e.g. `impression_count_30d > 10`), not raw interaction history. Use it when you want windowed-aggregate-driven caps.
* **`maxPerDay` resets at UTC midnight** unless `lookbackHours` is set. For rolling 24h, pass `lookbackHours: 24`.

## Proof reference

T4 (frequency\_cap 3/day fires) and T128 (#155 offer\_category\_cap denormalization fix verified) in the proof bundle.
