Skip to main content
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). 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.
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).
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.

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 listconfig.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.
  2. External sourceconfig.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.
  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).
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.
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: 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)

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:
Engagement health score formula (per customer, computed nightly from a 90-day rollup of the interaction-history fact table):
The formula is hardcoded in the engagement-health helper. Tenant-level overrides are not yet supported. Enabling the cron (nightly batch recompute):
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).

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 until suppressForDays 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):
Single-outcome (backward-compatible string 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

Like frequency_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.”
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: 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 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: 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 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): 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.
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.
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.

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.
Config fields: 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

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.

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

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

Suppression Expiry Modes

Each suppression record carries an expiresAt 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 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:
How it works: 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.

Field Reference

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

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:

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):

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:
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:

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.
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 page for configuration details.

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:
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:
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 for the full response shape.

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.