Contact Policies are post-qualification rules that remove Offers a customer is eligible for but should not receive right now. The decision-flow engine evaluates them after qualification rules 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 isMandatory) still respect frequency_cap and cross_channel_cap rules plus any policy where config.bypassable is false. All other blocking rules are skipped for mandatory Offers.
KaireonAI supports 12 rule types. Eleven are defined across the validation schema and domain rules; one (metric_condition) lives in the engine and domain layer but is not yet exposed in the API validation enum.
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
metric_condition
Block when a behavioral metric crosses a threshold
global, offer, creative, channel
metric_condition is implemented in the contact-policy engine but is not included in the API validation enum. All other eleven types (including customer_total_cap, offer_category_cap, and category_suppression) are fully wired end-to-end.
Limits how many times a customer can be contacted within rolling time windows. 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.
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.
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.
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)
The formula is hardcoded in the engagement-health helper. Tenant-level overrides are not yet supported.Enabling the cron (nightly batch recompute):
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 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).
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):
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.
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.
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.
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.
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.”
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.
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.
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.
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 candidate must carry an offerCategory matching
targetCategory. The engine then filters interaction summaries to the period
window, optionally to summary rows whose offerCategory matches (when
present), and blocks the candidate when totalImpressions >= maxTotal.
Graceful degradation: the interaction-summary rollup does not currently denormalize the offer’s category. Until that schema enrichment ships, the engine conservatively counts all impressions in the period, not just those in the target category. The cap therefore fires earlier than a fully filtered count would. Plan caps with this in mind, or wait for the follow-on phase that adds the offer category to the interaction-summary rollup.
An override that allows contact despite other blocking policies. When the engine finds a matching allow_override, it skips all blocking rules for that candidate. Use this for mandatory or time-sensitive Offers that must bypass normal frequency limits.Config fields:
Field
Type
Description
allowSegments
string[]
(Optional) Only apply override if customer is in one of these segments
allowOfferIds
string[]
(Optional) Only apply override for these specific Offer IDs
Runtime:allow_override policies are evaluated before all blocking rules. If both allowSegments and allowOfferIds are set, both conditions must match. The engine logs a warning for every override to maintain an audit trail.
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.
Blocks candidates when a behavioral metric value crosses a threshold. Supports dimension mapping to resolve metric values per candidate.
This rule type is implemented in the contact-policy engine and domain layer (contact-policy-rules.ts) but is not yet included in the API validation schema (api-validate.ts). To use it today, add it to the ruleType enum in the validation schema or write policies directly to the database.
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.
Volume Constraints 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 frequency caps (which are per-customer), volume constraints 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.”
Update an existing volume constraint (requires id)
DELETE
/api/v1/volume-constraints?id={id}
Delete a volume constraint
Use volume constraints alongside contact policies for complete control. Contact policies protect individual customers from over-contact; volume constraints protect your business from over-committing inventory or exceeding channel capacity.
Every Contact Policy operates within one of four scopes, which determines 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
Not every rule type supports every scope. The table in the Rule Types section above shows allowed scopes per rule type. The UI dynamically filters the scope dropdown based on your selected rule type.
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.
Each Contact Policy has a priority value from 0 to 100 (default: 50). Higher values are evaluated first.The engine resolves conflicts as follows:
Sort all active policies by priority descending.
Separateallow_override policies from blocking policies.
For each candidate, check allow_override policies first. If any matches, the candidate is explicitly allowed and all blocking rules are skipped.
Otherwise, evaluate blocking policies in priority order. The first rule that blocks removes the candidate.
Unknown rule types fail open (the candidate is allowed through) with a warning log. This prevents misconfigured rule types from silently killing all offers, but also means invalid policies provide zero protection.
This means a priority-100 allow_override will beat a priority-100 frequency_cap because overrides are always checked first regardless of priority.
Starting with recent releases, contact policies are enforced via pre-computed suppression records rather than being evaluated live at decision time. This architecture dramatically reduces latency in the Recommend API by replacing per-policy evaluation with a single database read.
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 the relevant scope (offer, creative, channel, or category).
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.
This means the cost of contact policy enforcement at decision time is constant regardless of how many policies are configured.
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.
The seven pre-computed types cover the vast majority of contact policies. The three live-evaluated types are inherently request-dependent and cannot be pre-computed.
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.
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:
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.
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.
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:
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" }
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 for full request and response schemas.
Contact policies and volume constraints are complementary mechanisms that are both enforced during the Contact Policy pipeline stage, but they protect different things:
Contact Policies
Volume Constraints
Protects
Individual customers
The business
Purpose
Frequency caps, 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 volume constraints filter the remaining candidates. A customer may pass all contact policy checks but still be blocked by a volume constraint if the offer, category, or channel has reached its delivery cap.
Use both together for complete control. Contact policies prevent individual customer fatigue; volume constraints prevent over-committing inventory or exceeding channel capacity. See the Volume Constraints page for configuration details.
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.The same data is available programmatically:
GET /api/v1/offers/:id/effective-rules
The endpoint requires any of the admin, editor, or viewer roles and returns both contact policies and qualification rules sorted by priority descending. See Decisioning Gates for the full response shape.