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

# Decisioning Gates

> Four-stage rule pipeline — Eligibility, Fit Filters, Match Scoring, Ranking — that decides which offers reach each customer.

<Note>
  **See also**: [Decisioning Gates REST API reference](/api-reference/qualification-rules) for request/response shapes, status codes, and error semantics.
</Note>

## Overview

A **Decisioning Gate** is a configurable rule that decides whether — and how strongly — a customer may receive a particular Offer. KaireonAI authors these rules in four ordered stages. All four are stored as **decisioning gate** records with a `stage` discriminator, and are evaluated inside the **Filter** (qualify) stage of a [Decision Flow](/decisioning/decision-flows):

| Stage             | Behavior                                                                                                                                                                                               |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Eligibility**   | Hard pass/fail filter — legal, compliance, do-not-contact checks. A failing rule **drops** the Offer from the candidate set immediately.                                                               |
| **Fit Filters**   | Hard product-fit filter — whether the offer could logically apply to this customer at all (e.g. the customer already owns the offer). Treated exactly like Eligibility: a failure **drops** the Offer. |
| **Match Scoring** | Soft multiplier — does not drop the Offer; instead contributes a multiplier that scales the Offer's score.                                                                                             |
| **Ranking**       | Authoring stage for rules intended to adjust the final ordering. **Not yet applied at decision time** — see the note below.                                                                            |

Both `eligibility` and `fit` are **hard filters**; only `match` performs **soft scoring**. Legacy stage values are auto-mapped: `qualification` → `eligibility`, `applicability` → `fit`, `suitability` → `match`.

Hard-filter rules are sorted by **priority** (highest first) and evaluated in order. The first hard-filter failure short-circuits — remaining rules for that Offer are skipped.

<Note>
  At decision time, **Eligibility** and **Fit** are evaluated together as **hard filters** (a failing offer is removed), and **Match** is the **soft multiplier** that scales each surviving offer's score. **Ranking**-stage rules are persisted and shown in the studio, but they do **not** currently affect offer ordering — neither the recommend runtime nor the batch executor reads stage-4 rules. Final ordering is governed by the ranking profile (Scoring Strategies), not by ranking-stage decisioning gates.
</Note>

All four stages are authored on the **Decisioning Gates** page in the studio sidebar. Ranking profiles (the weighting configuration that combines objectives) are still configured under **Scoring Strategies**, but the stage-4 rules themselves now live alongside the other gates.

***

## Authoring in the Studio

The studio surface at `/studio/qualification-rules` (label: **Decisioning Gates**) lists every rule in the tenant. A pill row at the top filters by stage:

* **All** — every rule regardless of stage
* **Eligibility** — hard gates only
* **Fit Filters** — product-fit only
* **Match Scoring** — soft-scoring multipliers only
* **Ranking** — final-ordering rules only

Each rule row exposes the rule type, scope assignments, priority, and the stage classification. Click any row to edit; click **+ New Decisioning Rule** to author a new one.

### Selecting a stage

The rule editor surfaces a 4-button stage selector arranged in a 2×2 grid. Pick the stage that best matches the rule's intent:

* Use **Eligibility** for legal/compliance gates that block the offer entirely (hard filter).
* Use **Fit Filters** for product-fit checks that should drop the offer when they fail (e.g. customer doesn't already own the offer) — also a hard filter.
* Use **Match Scoring** for soft scoring that multiplies the offer's score (e.g. propensity threshold, recency boost). This is the only stage that scales rather than drops.
* Use **Ranking** to author rules intended to adjust the final ordering after match scoring (e.g. campaign priority boosts, recency-bias adjustments). Note: ranking-stage rules are not yet read at decision time — see the note above.

The default rule type lookup adapts to the stage: `segment_required`, `attribute_condition`, `metric_condition` default to Eligibility; `propensity_threshold`, `recency_check` default to Match.

### AI parse rule

The new-rule form exposes an **AI parse rule** button. Paste a natural-language description of the rule (e.g. *"only customers in the US with balance over 10,000"*) and the assistant returns a draft rule type, config payload, and stage. Review and edit before saving — the parser is a starting point, not the source of truth.

***

## Rule Types

Kaireon ships six rule types you can assign to any of the four stages above (see each tab for which stages a type supports). Each type has its own `config` shape and runtime evaluation logic. Note the distinction between **`attribute_condition`** (tests a *customer* attribute) and **`offer_attribute`** (tests an *offer* field) — they share the same operator set but read from different sides of the decision.

<Tabs>
  <Tab title="segment_required">
    **Stage:** eligibility or fit (hard filter)

    Checks that the customer belongs to the required segment(s). If the customer is missing any required segment, the Offer is dropped.

    **Config JSON** — the canonical shape is a single `segmentId` (what the create-time schema requires):

    ```json theme={null}
    {
      "segmentId": "premium"
    }
    ```

    The engine also accepts a legacy `requiredSegments` array (AND logic across every listed segment) for back-compat:

    ```json theme={null}
    {
      "requiredSegments": ["premium", "high_value"]
    }
    ```

    **Runtime behavior**

    1. Read `segmentId` (or the legacy `requiredSegments` array) from config.
    2. Compare against the customer's segment list (auto-resolved from enriched `customer.segment` field, or provided in the Recommend request).
    3. If the customer is in **all** of the required segments, the rule **passes**.
    4. If segment data **is present** but the customer is missing a required segment, the rule **fails** with a reason like `Missing required segments: high_value`.
    5. If **no segment data is available at all** (not enriched and not provided in the request), the rule follows its [`onMissing` behavior](#missing-data-onmissing): with the default `"skip"` it is skipped (fail-open) with the reason `Skipped: no segment data available` **and a warning recorded in the decision trace**; with `"block"` the Offer is dropped. This prevents one segment rule from silently zeroing `/recommend` for every un-enriched customer — enrich or pass `segments` to actually enforce it.
    6. An empty `requiredSegments` array is treated as a pass (no restriction).
  </Tab>

  <Tab title="attribute_condition">
    **Stage:** eligibility or fit (hard filter)

    Compares a single customer attribute against an expected value using one of ten operators.

    **Config JSON**

    ```json theme={null}
    {
      "attribute": "customer.credit_score",
      "operator": "gte",
      "value": 720
    }
    ```

    **Supported operators**

    | Operator       | Meaning                                                                         | Example                           |
    | -------------- | ------------------------------------------------------------------------------- | --------------------------------- |
    | `eq`           | Equals                                                                          | `state eq "CA"`                   |
    | `neq`          | Not equals                                                                      | `status neq "closed"`             |
    | `gt`           | Greater than                                                                    | `income gt 50000`                 |
    | `gte`          | Greater or equal                                                                | `credit_score gte 720`            |
    | `lt`           | Less than                                                                       | `age lt 65`                       |
    | `lte`          | Less or equal                                                                   | `debt_ratio lte 0.4`              |
    | `in`           | Value in list                                                                   | `tier in ["gold","platinum"]`     |
    | `not_in`       | Value not in list                                                               | `tier not_in ["bronze","silver"]` |
    | `contains`     | String includes / array membership (case-sensitive)                             | `tags contains "vip"`             |
    | `not_contains` | Negation of `contains` — true when a list/string does **not** include the value | `opt_outs not_contains "all"`     |
    | `exists`       | Attribute is present and non-empty                                              | `email exists`                    |
    | `not_exists`   | Attribute is missing or empty                                                   | `legacy_id not_exists`            |

    `attribute_condition` and `offer_attribute` share the **same** operator set (the twelve above). An unrecognized operator is a no-op — the rule passes.

    <Note>`contains`, `not_contains`, and `not_in` are all supported end-to-end — the create-time Zod schema and the runtime engine agree on the full operator set for both `attribute_condition` and `offer_attribute`. `contains` does a case-sensitive string-includes test (or array membership when the attribute is a list); `not_contains` is its negation — the correct operator for a gate like "customer has not opted out of everything" where `opt_outs` is a list (`["all"]`).</Note>

    **Runtime behavior**

    1. Look up `attribute` in the [decision context](#the-decision-context) — any namespace works: `customer.credit_score`, `accounts.balance_sum`, `behavior.converts_30d`, `journey.current`, `attributes.tier`.
    2. If the attribute is **missing** and operator is not `exists`/`not_exists`, the rule follows its [`onMissing` behavior](#missing-data-onmissing): the default `"skip"` skips the rule (fail-open) and records the warning `attribute "<attr>" not present in decision context` in the decision trace; `"block"` drops the Offer with `Required attribute "<attr>" not present`.
    3. For `exists`/`not_exists`, a missing attribute is valid input (not an error).
    4. Apply the operator. If the comparison is false, the rule fails with a reason containing the actual vs expected values.
  </Tab>

  <Tab title="propensity_threshold">
    **Stage:** eligibility/fit (hard filter) or match (soft scoring)

    Supports two modes for threshold checks:

    **Mode 1: Model-based** -- requires a named propensity model's score to meet a minimum threshold.

    ```json theme={null}
    {
      "propensityModel": "cc_propensity_v3",
      "minScore": 40
    }
    ```

    **Mode 2: Attribute-based** -- checks an enriched customer attribute against a threshold using a comparison operator. Useful for data-driven gates like churn risk or credit score.

    ```json theme={null}
    {
      "attribute": "customer.churn_risk",
      "operator": "lt",
      "threshold": 0.8
    }
    ```

    Supported operators for attribute mode: `gte`, `gt`, `lte`, `lt`.

    **Runtime behavior -- hard filter (eligibility/fit stage), model-based**

    1. Look up `propensityModel` in the customer's `propensityScores` map.
    2. If **no score exists yet** for the model (cold-start model, or the score stage hasn't run), the rule follows its [`onMissing` behavior](#missing-data-onmissing) — default `"skip"` skips it (fail-open) with a traced warning. Blocking here would deadlock — the gate would drop the offer so the model never earns the data to produce a score — so only set `"block"` if you accept that trade.
    3. If a score exists and is below `minScore`, the rule **fails**.

    **Runtime behavior -- hard filter (eligibility/fit stage), attribute-based**

    1. Look up `attribute` in the decision context.
    2. If the attribute is **missing** (not enriched), the rule follows its [`onMissing` behavior](#missing-data-onmissing) — default `"skip"` skips it (fail-open) with a traced warning, consistent with `attribute_condition`.
    3. Apply the operator comparison against `threshold`. If false, the rule **fails** with actual vs expected values.

    **Runtime behavior -- soft scoring (match stage)**

    1. Only the model-based form contributes a multiplier here — soft scoring reads `propensityModel` + `minScore` (the attribute-based form is not scored).
    2. If no score exists, the multiplier is 1.0 (no penalty).
    3. If the score meets `minScore`, the multiplier is 1.0.
    4. If below `minScore`, the multiplier is `score / minScore` (linear decay, **no floor** — it can approach 0).
  </Tab>

  <Tab title="recency_check">
    **Stage:** eligibility/fit (hard filter) or match (soft scoring)

    Verifies that a customer-attribute representing "days since last activity" is within an acceptable window.

    **Config JSON** — the canonical key is `withinDays` (what the create-time schema requires):

    ```json theme={null}
    {
      "attribute": "days_since_last_login",
      "withinDays": 30
    }
    ```

    The legacy `maxDays` key is still accepted by the hard-filter path.

    **Runtime behavior -- hard filter (eligibility/fit stage)**

    1. Read the named attribute from the decision context.
    2. If the attribute is **missing**, the rule follows its [`onMissing` behavior](#missing-data-onmissing) — default `"skip"` skips it (fail-open) with a warning recorded in the decision trace; `"block"` drops the Offer.
    3. If the value exceeds `withinDays` (or legacy `maxDays`), the rule **fails**.

    **Runtime behavior -- soft scoring (match stage)**

    1. If the attribute is missing, the multiplier is 1.0.
    2. If the value is within the day limit, the multiplier is 1.0.
    3. If the value exceeds the limit, the multiplier decays as `maxDays / actualDays`, floored at 0.1. Note: the soft-scoring path reads only `maxDays` (not `withinDays`).
  </Tab>

  <Tab title="metric_condition">
    **Stage:** eligibility or fit (hard filter)

    Evaluates a [Behavioral Metric](/studio/behavioral-metrics) value against a threshold. Use this to enforce business limits such as "no more than 10 impressions per month" or "at least 3 clicks before upgrade offer."

    In the Decisioning Gates editor, the **Metric** field is a dropdown populated from your tenant's Behavioral Metrics (not a free-text `metricId`). If the selected metric is grouped by one or more `groupByDimensions`, the editor also shows a **dimension mapping** panel with one input per dimension.

    **Config JSON**

    ```json theme={null}
    {
      "metricId": "monthly_impressions",
      "operator": "lte",
      "threshold": 10,
      "dimensionMapping": {
        "offerId": "$candidate.offerId"
      }
    }
    ```

    **Supported operators:** `gt`, `gte`, `lt`, `lte`, `eq`

    <Note>The engine reads `threshold`. For back-compat the create/update API also accepts a legacy `value` field and normalizes it to `threshold` when `threshold` is absent, so `{ "operator": "lt", "value": 10 }` and `{ "operator": "lt", "threshold": 10 }` behave identically. `neq` is not a supported operator here.</Note>

    **Dimension mapping**

    When the target metric is grouped (has `groupByDimensions`), `dimensionMapping` tells the engine which value to look up for each dimension at decision time. `groupByDimensions` -- and therefore `dimensionMapping` -- isn't limited to the four fixed dimensions: a metric grouped by a registered [custom dimension](/studio/behavioral-metrics#custom-dimensions) (e.g. `campaign`, `region` -- see the [Dimensions registry](/studio/dimensions)) is mapped the same way, keyed by that dimension's registered `key`. The editor's dimension-mapping panel accepts:

    * `$candidate.offerId` -- resolves to the ID of the Offer currently being evaluated.
    * `$candidate.channelId` -- resolves to the channel the Offer is being evaluated for.
    * A literal value (e.g. `"email"`, an outcome key like `"click"`, or a custom-dimension value like `"summer24"`) -- matches that exact dimension value regardless of the candidate.
    * Omitted or blank for a dimension -- matches the un-dimensioned total for that dimension (the aggregate computed with no filter on it).

    <Info>
      Decisioning Gates evaluate at the **Offer level, before a creative is chosen**, so `$candidate.offerId` and `$candidate.channelId` resolve per-candidate but a `creativeId` is not available yet. For a metric grouped by `creativeId` (or `outcomeType`), map it to a **literal value** rather than a `$candidate.*` token. (Contact Policies run post-creative, so their `metric_condition` additionally resolves `$candidate.creativeId`.) There is no `$candidate.*` token for a custom dimension -- only `$candidate.offerId` and `$candidate.channelId` are recognized tokens, so map a custom dimension to a literal value.
    </Info>

    **Runtime behavior**

    1. Build a lookup key from `metricId` + resolved `dimensionMapping` values. The `$candidate.offerId` and `$candidate.channelId` tokens are replaced with the current candidate's values; every other mapping value is used literally.
    2. If no metric map was loaded at all, or the metric has not been computed for this dimension key, the rule follows its [`onMissing` behavior](#missing-data-onmissing): the default `"skip"` skips it (fail-open, with a traced warning) — it is **not** evaluated against `0`; `"block"` drops the Offer instead. Default-skip prevents a "less-than-threshold" rule from silently disqualifying every Offer when the metric is simply absent.
    3. When the value is present, apply the operator. If the condition **triggers** (evaluates to true), the rule **fails** and the Offer is dropped.

    <Note>
      The operator semantics are inverted compared to `attribute_condition`: when the condition **is met** the Offer is disqualified. Think of it as defining the *disqualification* condition. For example, `"operator": "gt", "threshold": 10` means "disqualify when impressions exceed 10."
    </Note>
  </Tab>

  <Tab title="offer_attribute">
    **Stage:** eligibility or fit (hard filter)

    Filters on offer-level fields (e.g., `productType`, `margin`, `revenueValue`, or custom fields defined in the offer's metadata). This lets you write rules that target specific offer properties rather than customer attributes.

    **Config JSON**

    ```json theme={null}
    {
      "attribute": "productType",
      "operator": "eq",
      "value": "credit_card"
    }
    ```

    **Supported operators**

    `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `contains`, `exists`, `not_exists` — the identical operator set used by `attribute_condition`.

    **Runtime behavior**

    1. Look up `attribute` on the offer object. The engine checks top-level offer fields first, then falls back to `metadata.customFieldValues`.
    2. If the attribute is **missing** and operator is not `exists`/`not_exists`, the rule follows its [`onMissing` behavior](#missing-data-onmissing) — default `"skip"` skips it (fail-open, traced warning; unknown field = don't block), `"block"` drops the Offer.
    3. For `exists`/`not_exists`, a missing attribute is valid input.
    4. Apply the operator. If the comparison is false, the rule fails with a reason like `Offer "productType" eq "credit_card" failed (actual: "loan")`.
  </Tab>
</Tabs>

<Warning>
  **Unknown rule types fail closed.** If a rule's `ruleType` is not one of the six above (for example a corrupted or future-version rule), the engine **blocks** the candidate rather than passing it, and logs the event (`error` in production, `warn` otherwise). This is deliberate — a misconfigured rule must never silently let unqualified candidates through.
</Warning>

***

## The decision context

Every attribute-reading gate resolves its `attribute` against the **canonical decision context** — a flat, entity-namespaced record the engine assembles automatically at the start of every Recommend call (no Enrich node required). The available namespaces:

| Namespace      | Source                                                                                                                                   | Example                                  |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `customer.*`   | The tenant's base `customer` schema row                                                                                                  | `customer.credit_score`                  |
| `<entity>.*`   | Each active [Schema Join](/api-reference/schema-joins) with `autoEnrich: true`, one-to-many rows rolled up via the join's `aggregations` | `accounts.balance_sum`, `accounts.count` |
| `behavior.*`   | [Behavioral Metric](/studio/behavioral-metrics) values for this customer, keyed by the slugified metric name                             | `behavior.converts_30d`                  |
| `journey.*`    | Active [Journey](/studio/journeys) enrollment state                                                                                      | `journey.current`, `journey.step`        |
| `attributes.*` | Request-time attributes from the Recommend body                                                                                          | `attributes.tier`                        |

See [Decision Flows — the decision context](/decisioning/decision-flows#the-decision-context-auto-assembled) for full assembly semantics, including the deprecated legacy `<schemaname>.*` aliases.

### The attribute picker (Studio) and preview endpoint

You don't have to guess which keys exist. In **Studio → Decisioning Gates**, the `attribute` field of `attribute_condition` and `recency_check` rules is an **autocomplete picker**: type to filter the real assembled namespace and pick a verified key — each suggestion shows the key, its entity (for joined schemas), and its value type. Picking a real key at author time removes the most common cause of silently skipped gates (a gate written against `customer.tier` when the data actually lands under `retail_customers.tier`). Free text is still accepted as an advanced fallback — the key just has to exist in the assembled context at decision time, or the rule follows its [`onMissing` behavior](#missing-data-onmissing).

The picker is backed by [`GET /api/v1/decision-context/preview`](/api-reference/decision-context-preview), which returns the full key catalog (`[{ key, type, source, entity?, description? }]`) built from your tenant's metadata — customer schema columns, each active Schema Join's configured aggregations (plus the raw `<entity>[]` collection), active Behavioral Metrics, journey state, and the documented request attributes. Pass `?flowKey=` to scope the catalog to one flow (applies its Enrich-node `excludeJoinIds[]` opt-outs).

<Note>
  `behavior.<metricKey>` reads the **same** MetricValue rows the `metric_condition` gate reads — identical freshness, never recomputed at decision time. Use `behavior.*` in an `attribute_condition` gate (or a compute formula / scoring predictor) for customer-level metric values; keep using `metric_condition` when you need per-dimension lookups via `dimensionMapping`.
</Note>

***

## Missing data: `onMissing`

Every rule type's `config` accepts an optional **`onMissing`** field controlling what happens when the data the rule needs is absent from the decision context (attribute not present, no segment data, metric not computed, no propensity score):

| Value                | Behavior                                                                                                                                                                                                                                                                                                               |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"skip"` *(default)* | **Fail-open, visibly.** The rule is skipped and the candidate stays eligible for this rule — but the skip is **recorded as a structured warning in the decision trace** (`skipped: true` plus a `warning` such as `attribute "customer.credit_score" not present in decision context`). It is no longer a silent pass. |
| `"block"`            | **Fail-closed.** The candidate is blocked with the reason `Required attribute "<attr>" not present`.                                                                                                                                                                                                                   |

```json theme={null}
{
  "attribute": "customer.kyc_verified",
  "operator": "eq",
  "value": true,
  "onMissing": "block"
}
```

<Warning>
  **Eligibility and compliance gates should set `onMissing: "block"`.** The default `"skip"` exists so a missing enrichment field doesn't zero out `/recommend` for every un-enriched customer — but for legal, regulatory, or do-not-sell gates, absence of the attribute must mean *not eligible*, not *assume eligible*.
</Warning>

`onMissing` is stored inside the rule's `config` (no migration needed) and is validated at the API boundary as `"skip" | "block"`. It applies to the missing-data path of all six rule types; when the data **is** present, the rule evaluates normally regardless of `onMissing`.

***

## Scopes

Every **Decisioning Gate** has a **scope** that controls which Offers it applies to. Narrower scopes let you write rules that target specific parts of your catalog without affecting everything else.

| Scope         | `scopeId` resolves to | When it applies                               |
| ------------- | --------------------- | --------------------------------------------- |
| `global`      | *(ignored)*           | Every Offer in the tenant                     |
| `segment`     | Segment ID            | Only when the customer belongs to the segment |
| `channel`     | Channel ID            | Only when the request targets that channel    |
| `category`    | Category ID           | Only Offers in that Category                  |
| `subcategory` | SubCategory ID        | Only Offers in that SubCategory               |
| `offer`       | Offer ID              | Only that specific Offer                      |
| `placement`   | Placement ID          | Only when the request targets that placement  |

If a rule's scope does not match the candidate Offer, the rule is **skipped** (treated as a pass).

<Info>
  When `scopeId` is null for a non-global scope, the rule applies to **all** entities at that scope level. For example, a rule with `scope: "category"` and `scopeId: null` applies to every Category.
</Info>

***

## Stages: hard filters vs match scoring

The `stage` field selects how a failing rule affects the candidate. At runtime the engine collapses stages into two behaviors: **hard filter** (`eligibility` + `fit`) and **match scoring** (`match`).

|                                 | Hard filter (`eligibility` / `fit`) | Match scoring (`match`)                                                                                 |
| ------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **On failure**                  | Offer removed from candidate set    | Offer score multiplied by a decay factor                                                                |
| **Rule types that participate** | any rule type                       | `propensity_threshold` and `recency_check` only — all other types default to a 1.0 multiplier           |
| **Multiplier range**            | N/A                                 | `recency_check`: floored at 0.1. `propensity_threshold`: `score / minScore`, no floor (can approach 0). |
| **Combination**                 | First failure short-circuits        | All match multipliers are combined multiplicatively                                                     |

When multiple match rules apply to the same Offer, their multipliers are multiplied together. For example, if a propensity rule returns 0.8 and a recency rule returns 0.5, the final multiplier is `0.8 x 0.5 = 0.4`.

***

## Evaluation Order

During the Filter (qualify) stage of a Decision Flow, rules are evaluated as follows:

1. Load active Decisioning Gates according to the qualify node's mode (`all`, `selected`, or `none`).
2. Classify each rule and split into **hard-filter** rules (`eligibility` + `fit` stages) and **match-scoring** rules (`match` stage).
3. Sort each group by **priority** descending (highest priority first).
4. For each candidate Offer:
   * Evaluate all **hard-filter** rules whose scope matches the candidate. First failure drops the Offer.
   * Evaluate all **match-scoring** rules whose scope matches the candidate. Accumulate the combined multiplier.
5. Surviving Offers proceed to the Scoring stage with their match multiplier applied.

### Decision Flow Filter Modes

| Mode         | Behavior                                                                     |
| ------------ | ---------------------------------------------------------------------------- |
| **all**      | Every active Decisioning Gate in the tenant is evaluated.                    |
| **selected** | Only the rules whose IDs are listed in `qualificationRuleIds` are evaluated. |
| **none**     | Qualification is skipped entirely.                                           |

<Warning>
  Setting the filter mode to **none** bypasses all Decisioning Gates. Offers may be recommended to ineligible customers. Use with caution.
</Warning>

***

## AND/OR Logic Trees

For advanced scenarios, Kaireon supports recursive AND/OR logic groups via a nested logic-group structure. This allows you to compose rules into arbitrarily nested boolean expressions.

```json theme={null}
{
  "operator": "AND",
  "ruleIds": ["rule_segment_premium", "rule_credit_720"],
  "groups": [
    {
      "operator": "OR",
      "ruleIds": ["rule_high_propensity", "rule_recent_login"]
    }
  ]
}
```

The example above evaluates as:

```
rule_segment_premium AND rule_credit_720 AND (rule_high_propensity OR rule_recent_login)
```

**Vacuous truth rules:**

* An **empty AND group** (no ruleIds, no sub-groups) evaluates to **true**.
* An **empty OR group** evaluates to **false**.

The evaluator walks the tree recursively: each group collects boolean results from its `ruleIds` (via the qualification engine) and its nested `groups`, then applies the group's operator (`"AND"` = every result must be true; `"OR"` = at least one must be true).

***

## Field Reference

All fields accepted by the `POST /api/v1/qualification-rules` endpoint:

| Field         | Type             | Required | Default         | Description                                                                                                                                                                                                                                   |
| ------------- | ---------------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | `string`         | Yes      | --              | Unique display name for the rule.                                                                                                                                                                                                             |
| `description` | `string`         | No       | `""`            | Free-text description.                                                                                                                                                                                                                        |
| `status`      | `enum`           | No       | `"active"`      | One of `draft`, `active`, `paused`, `archived`. Only `active` rules are evaluated at decision time.                                                                                                                                           |
| `scope`       | `enum`           | No       | `"global"`      | One of `global`, `segment`, `channel`, `category`, `subcategory`, `offer`, `placement`.                                                                                                                                                       |
| `scopeId`     | `string \| null` | No       | `null`          | The ID of the entity the scope targets (e.g., a Category ID when `scope` is `category`). Null means "all entities at this scope level."                                                                                                       |
| `ruleType`    | `enum`           | Yes      | --              | One of `segment_required`, `attribute_condition`, `propensity_threshold`, `recency_check`, `metric_condition`, `offer_attribute`.                                                                                                             |
| `config`      | `object`         | No       | `{}`            | Rule-type-specific configuration (see each rule type tab above).                                                                                                                                                                              |
| `priority`    | `integer`        | No       | `50`            | 0--100. Higher-priority rules are evaluated first.                                                                                                                                                                                            |
| `stage`       | `enum`           | No       | `"eligibility"` | One of `eligibility`, `fit`, `match`, `ranking`. `eligibility` and `fit` are hard filters; `match` performs soft scoring. Legacy values `qualification` / `applicability` / `suitability` are auto-mapped to `eligibility` / `fit` / `match`. |

***

## Worked Example

Customer **C-4821** requests a decision for the **"Gold Card Upgrade"** Offer. Three Decisioning Gates are active.

### Rule definitions

| # | Name                 | Type                  | Scope                   | Config                                                                                                                              |
| - | -------------------- | --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Premium Segment Gate | `segment_required`    | `global`                | `{ "requiredSegments": ["premium"] }`                                                                                               |
| 2 | Min Credit Score     | `attribute_condition` | `category:credit-cards` | `{ "attribute": "customer.credit_score", "operator": "gte", "value": 720 }`                                                         |
| 3 | Impression Cap       | `metric_condition`    | `global`                | `{ "metricId": "monthly_impressions", "operator": "gt", "threshold": 10, "dimensionMapping": { "offerId": "$candidate.offerId" } }` |

### Pass scenario (credit\_score = 745)

Customer context:

* Segments: `["premium", "high_value"]`
* `customer.credit_score`: **745**
* `monthly_impressions` metric value: **8**

| Rule                      | Check                                      | Result   |
| ------------------------- | ------------------------------------------ | -------- |
| 1 -- Premium Segment Gate | `"premium"` in segments?                   | **Pass** |
| 2 -- Min Credit Score     | `745 >= 720`?                              | **Pass** |
| 3 -- Impression Cap       | `8 > 10`? false -- condition not triggered | **Pass** |

The Offer survives and proceeds to scoring.

**Debug trace (abbreviated):**

```json theme={null}
{
  "totalCandidates": 12,
  "afterQualification": 9,
  "qualificationReasons": []
}
```

No entries in `qualificationReasons` for this Offer because all three rules passed.

### Fail scenario (credit\_score = 680)

Same customer but with `customer.credit_score` = **680**.

| Rule                      | Check                    | Result   |
| ------------------------- | ------------------------ | -------- |
| 1 -- Premium Segment Gate | `"premium"` in segments? | **Pass** |
| 2 -- Min Credit Score     | `680 >= 720`?            | **Fail** |

Rule 2 fails. The engine short-circuits -- Rule 3 is not evaluated. The Offer is dropped.

**Debug trace (abbreviated):**

```json theme={null}
{
  "totalCandidates": 12,
  "afterQualification": 8,
  "qualificationReasons": [
    {
      "offerId": "offer_gold_card_upgrade",
      "creativeId": "",
      "reason": "Attribute \"customer.credit_score\" gte 720 failed (actual: 680)",
      "policyId": "qr_min_credit_score"
    }
  ]
}
```

***

## Migrating from legacy stage names

Tenants whose data was created before this rename may have rows with legacy `stage` values. The classifier transparently maps them so existing data keeps working:

| Legacy        | Current     |
| ------------- | ----------- |
| qualification | eligibility |
| applicability | fit         |
| suitability   | match       |

To physically migrate the rows in the database (recommended for tidiness), run:

```bash theme={null}
cd platform && npx tsx ../tools/scripts/migrate-decisioning-stage.ts --tenant <tenantId> --apply
```

The script defaults to dry-run; pass `--apply` to mutate. It returns a JSON summary of how many rows were updated per legacy value.

***

## API Quick Reference

### Create a Decisioning Gate

```bash theme={null}
POST /api/v1/qualification-rules
```

```json theme={null}
{
  "name": "Premium Segment Gate",
  "ruleType": "segment_required",
  "scope": "global",
  "stage": "eligibility",
  "priority": 80,
  "config": {
    "segmentId": "premium"
  }
}
```

**Response:** `201 Created` with the full rule object including `id`, `createdAt`, and `updatedAt`. The `stage` field defaults to `eligibility` when omitted.

### List Decisioning Gates

```bash theme={null}
GET /api/v1/qualification-rules
```

Returns a paginated list sorted by priority (descending), then by creation date (descending). Supports cursor-based pagination via `cursor` and `limit` query parameters, and stage filtering via `?stage=eligibility|fit|match|ranking`. Each returned row is enriched with a classifier-derived `decisioningStage` field so the UI can render the stage label without re-classifying client-side.

### Update a Decisioning Gate

```bash theme={null}
PUT /api/v1/qualification-rules
```

Send the rule `id` plus any fields to update. Only provided fields are changed.

### Delete a Decisioning Gate

```bash theme={null}
DELETE /api/v1/qualification-rules?id={ruleId}
```

Soft-deletes the rule (retains the record with a `deletedAt` timestamp). Returns `{ "deleted": true, "warnings": [...] }`. If the rule is referenced by any Decision Flow's `draftConfig`, the response includes a `warnings` array listing affected flows (ghost reference check).

For complete request/response schemas, see the [API Reference](/api-reference).

***

## Effective Rules — inheritance view per offer

Each offer rolls up rules from four scope levels:

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

A **decisioning gate** (or **contact policy**) attached to a category applies to every offer in that category; a rule attached to a subcategory narrows that further; a rule attached to an offer applies only to that offer; a rule with `scope = "global"` applies to every offer in the tenant.

**Channel and creative scopes are intentionally excluded from this view.** They evaluate at decision time and require a specific channel/creative the offer is being delivered through. Operators inspect those via [Decision Traces](/api-reference/decision-traces).

### Where to find it

Open any offer in `/studio/actions`, click into the detail view, and click **Effective Rules** in the top action bar. The page renders two tables — Contact Policies and Decisioning Rules — each annotated with the matched scope (global / category / subcategory / offer).

### API

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

Returns:

```json theme={null}
{
  "offerId": "<uuid>",
  "categoryId": "<uuid>|null",
  "subCategoryId": "<uuid>|null",
  "contactPolicies": [
    {
      "id": "<uuid>",
      "name": "Daily frequency cap",
      "ruleType": "frequency_cap",
      "config": { "maxPerDay": 3 },
      "priority": 80,
      "status": "active",
      "source": "contact_policy",
      "stage": null,
      "matchedScope": { "scope": "category", "scopeId": "<uuid>" }
    }
  ],
  "qualificationRules": [
    {
      "id": "<uuid>",
      "name": "US residents only",
      "ruleType": "segment_required",
      "config": { "segmentId": "<uuid>" },
      "priority": 100,
      "status": "active",
      "source": "qualification_rule",
      "stage": "eligibility",
      "matchedScope": { "scope": "global", "scopeId": null }
    }
  ]
}
```

The endpoint requires any of the `admin`, `editor`, or `viewer` roles. Both lists are sorted by priority descending. The `matchedScope` field tells the UI **why** the rule applies (e.g., "rule X applies because of category Y"); legacy single-scope rows that haven't been migrated to the multi-scope `scopes[]` relation still resolve correctly.

***

## Related

<CardGroup cols={2}>
  <Card title="Contact Policies" icon="shield" href="/decisioning/contact-policies">
    Frequency caps and cooldown rules that limit how often a customer is contacted.
  </Card>

  <Card title="Decision Flows" icon="sitemap" href="/decisioning/decision-flows">
    Orchestrate Decisioning Gates, scoring, and ranking into a complete decision pipeline.
  </Card>

  <Card title="Behavioral Metrics" icon="chart-bar" href="/studio/behavioral-metrics">
    Define the metrics used by metric\_condition rules.
  </Card>

  <Card title="Composable Pipeline" icon="diagram-project" href="/data/transforms/composable-pipeline">
    The v2 pipeline includes a dedicated qualify node for inline qualification.
  </Card>
</CardGroup>
