See also: Contact Policies REST API reference for request/response shapes, status codes, and error semantics.
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). Anallow_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.
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).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 ado_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):
-
Explicit list —
config.customerIds: [...]on a policy (typicallyscope: "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. -
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 in the recommend pipeline (getConsent+hasConsent), which suppresses candidates for channels whose consent was revoked. Industry-accelerator templates seed this shape by default. -
scope: "customer"withscopeIdset to the customer’s id — a single policy row pinned to one customer, evaluated inscopeMatches()by comparing the decision’scustomerIdtopolicy.scopeId. This is a narrower alternative to option 1 for a one-off, individually authored opt-out record rather than maintaining a sharedcustomerIdsarray — both mechanisms coexist.scope: "customer"requires the caller to have threaded acustomerIdthrough to the policy engine (see below); without one, the policy never matches (fails closed, not open).
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:
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).
Batch campaign 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.Preview Impact button (UI)
Thefrequency_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)
outcomeType: "impression". Set at least one
cap field before previewing — the button stays disabled until then.
Engagement-aware caps (optional)
Bothfrequency_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:
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).
cooldown
Enforces a minimum wait period (in hours) since the last contact before the same Offer can be served again. Config fields:
Runtime: Looks up
lastContactAt from interaction summaries and blocks if fewer than cooldownHours have elapsed.
budget_exhausted
Suppresses an Offer when its impression count or spend crosses a threshold. The engine checks alltime summary records. Config fields:
Runtime: Reads the alltime summary for the exact offer + creative + channel combination and blocks when the budget field meets or exceeds
threshold.
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 untilsuppressForDays have passed.
Config fields:
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):
segment_exclusion
Blocks all Offers for customers belonging to one or more excluded segments. This is a global-only rule. Config fields:
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.
time_window
Restricts contacts to specific hours and/or days of the week. Supports IANA timezone strings validated at write time. Config fields:
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.
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:
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.
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:
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.
cross_channel_cap
Likefrequency_cap, but aggregates impressions across all channels for the same Offer within a period.
Config fields:
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.”
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 fromcross_channel_cap:
Config fields:
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.offer_category_cap
Caps the number of contacts a customer can receive in a specificOffer.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:
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.
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.allow_override
An override that allows contact despite other blocking policies. When anallow_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):
Matching rules — evaluated before all blocking rules:
- A mandatory Offer matches immediately when
allowMandatory: true. - When
allowSegmentsis set, the customer must be in one of the listed segments; whenallowOfferIdsis set, the Offer must be in the list. If both are set, both must hold. - An override whose only field is
allowMandatory: trueapplies to mandatory Offers only — it does not un-block non-mandatory candidates. - An override with no conditions at all (
{}) is a blanket override — it matches every candidate. Scope it (via the policy’sscope/scopeId) or add a condition unless you truly mean “bypass contact policy for everyone.”
metric_condition
Blocks candidates when a behavioral metric value crosses a threshold. Supports dimension mapping to resolve metric values per candidate.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.
Runtime: Looks up the metric value using the dimension mapping, applies the operator, and blocks if the condition is met.
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
Configuration
Each frequency cap specifies a scope, period, and maximum count:
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:
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
Scopes
The API accepts six scope values on a Contact Policy, which determine which candidates it applies to.
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.
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.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:- Sort all active policies by priority descending.
- Separate
allow_overridepolicies from blocking policies. - For each candidate, check
allow_overridepolicies 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 closed — the candidate is blocked (“
Unknown rule type: "…" — blocked for safety”) and the engine logserrorin production (warnotherwise). A misconfigured or corruptedruleTypesuppresses the candidate rather than letting it slip through; watch the logs for these entries.
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
- Write path (Respond API): When a
respondcall 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). - 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:
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.Suppression Expiry Modes
Each suppression record carries anexpiresAt timestamp. The engine supports three expiry calculation modes:
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.Escalating Suppressions
When a customer repeatedly triggers the same policy, the suppression duration escalates automatically. Each suppression record tracks atriggerCount 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:
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.
Field Reference
All fields accepted by thePOST /api/v1/contact-policies endpoint:
Worked Example
Setup
Customer C-4821 has already received 3 emails this week for the “Spring Promo” Offer. Afrequency_cap policy limits the email channel to 3 per week.
Policy:
Step 1 — Frequency Cap Blocks
When the Recommend API runs for customer C-4821, the engine:- Loads the weekly interaction summary:
impressions = 3forch_emailin the current ISO week. - Evaluates
frequency_cap:3 >= 3(maxPerWeek) — BLOCKED. - The candidate is removed from the result set.
Step 2 — Allow Override Bypasses the Cap
Now suppose a regulatory notice must reach C-4821 regardless of frequency limits. Anallow_override policy exists:
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:
API Quick Reference
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 vs Frequency Caps
Contact policies and frequency caps are complementary mechanisms that are both enforced during the Contact Policy pipeline stage, but they protect different things:
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.
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:
admin, editor, or viewer roles and returns both contact policies and decisioning gates sorted by priority descending. See Decisioning Gates for the full response shape.
Related
Decisioning Gates
Rules evaluated before Contact Policies that determine initial Offer eligibility.
Frequency Caps
System-wide delivery caps on offers, categories, and channels.
Behavioral Metrics
Create metrics from interaction data to drive metric_condition policies.
Decision Flows
The pipeline that orchestrates qualification, contact policies, scoring, and ranking.