> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lexq.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Policy Rules

> Rules define the if-then logic of your policies. Learn condition syntax, action types, conflict resolution, and decision trace.

## What is a Policy Rule?

A **Policy Rule** is a condition → actions pair. When the input facts satisfy the condition, the rule's actions fire. Rules are evaluated in **priority order** (0 = highest priority).

```
IF condition matches → THEN execute actions
```

Each rule belongs to a specific policy version and has a name, a priority, a condition (tree-structured logic), and one or more actions.

## Condition Syntax

Conditions use a tree structure with two node types: `SINGLE` and `GROUP`.

<Warning>
  LexQ uses its own condition DTO format with `type: "SINGLE"` / `type: "GROUP"`, `field`, `operator`, `value`, and `valueType` fields. Do not confuse this with the Console's internal form representation which may use different field names. **Always use the engine format when calling the API or CLI.**
</Warning>

### SINGLE Condition

A leaf node that compares a single fact against a value:

```json theme={null}
{
  "type": "SINGLE",
  "field": "payment_amount",
  "operator": "GREATER_THAN_OR_EQUAL",
  "value": 100000,
  "valueType": "NUMBER"
}
```

### GROUP Condition

A branch node that combines child conditions with `AND` or `OR`:

```json theme={null}
{
  "type": "GROUP",
  "operator": "AND",
  "children": [
    { "type": "SINGLE", "field": "customer_tier", "operator": "EQUALS", "value": "VIP", "valueType": "STRING" },
    { "type": "SINGLE", "field": "payment_amount", "operator": "GREATER_THAN", "value": 50000, "valueType": "NUMBER" }
  ]
}
```

### Operators

| Operator                | Compatible Types | Description                       |
| ----------------------- | ---------------- | --------------------------------- |
| `EQUALS`                | All              | Exact match                       |
| `NOT_EQUALS`            | All              | Negation                          |
| `GREATER_THAN`          | NUMBER           | `>`                               |
| `GREATER_THAN_OR_EQUAL` | NUMBER           | `>=`                              |
| `LESS_THAN`             | NUMBER           | `<`                               |
| `LESS_THAN_OR_EQUAL`    | NUMBER           | `<=`                              |
| `CONTAINS`              | STRING           | Substring match                   |
| `IN`                    | STRING, NUMBER   | Value is in the provided list     |
| `NOT_IN`                | STRING, NUMBER   | Value is not in the provided list |

<Info>
  For `IN` / `NOT_IN`, the **Compatible Types** column refers to the type of the *fact* being checked (`STRING` or `NUMBER`). The `value` you provide is the list itself — its `valueType` is `LIST_STRING` or `LIST_NUMBER`.
</Info>

### Value Types

| Type          | JSON Value     | Example          |
| ------------- | -------------- | ---------------- |
| `STRING`      | `"string"`     | `"VIP"`          |
| `NUMBER`      | `number`       | `100000`         |
| `BOOLEAN`     | `true / false` | `true`           |
| `LIST_STRING` | `["a", "b"]`   | `["KR", "US"]`   |
| `LIST_NUMBER` | `[1, 2]`       | `[10000, 20000]` |

### Nested Conditions Example

`(customer_tier = "VIP" AND payment_amount >= 100000) OR region IN ["KR", "JP"]`

```json theme={null}
{
  "type": "GROUP",
  "operator": "OR",
  "children": [
    {
      "type": "GROUP",
      "operator": "AND",
      "children": [
        { "type": "SINGLE", "field": "customer_tier", "operator": "EQUALS", "value": "VIP", "valueType": "STRING" },
        { "type": "SINGLE", "field": "payment_amount", "operator": "GREATER_THAN_OR_EQUAL", "value": 100000, "valueType": "NUMBER" }
      ]
    },
    { "type": "SINGLE", "field": "region", "operator": "IN", "value": ["KR", "JP"], "valueType": "LIST_STRING" }
  ]
}
```

<Info>
  In the example above, `customer_tier`, `region`, and `payment_amount` are custom facts that should be registered in [Fact Definitions](/guides/fact-definitions). You can reference a fact before registering it — LexQ flags any unregistered key with a non-blocking warning rather than rejecting the rule — but registering enables type validation and the requirements analyzer. Only `user_id` and `user_tags` are system facts available by default.
</Info>

## Action Types

LexQ engine actions are **domain-agnostic primitives**. The engine sees only numbers and structures — it does not assume commerce, fintech, insurance, or any specific business model. Domain-specific semantics live in your fact names and integration payloads.

| Type                | Description                                                 | Key Parameters                                                                             |
| ------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `MUTATE_FACT`       | Mutate a numeric fact in place using an arithmetic operator | `refVar`, `operator`, `method`, `rate` (when PERCENTAGE) or `value`, `rounding` (optional) |
| `INCREMENT_FACT`    | Increment a numeric fact by a calculated amount             | `targetVar`, `method`, `refVar`,`rate` (when PERCENTAGE) or `value`, `rounding` (optional) |
| `EMIT_EVENT`        | Emit an event to an external integration                    | `integrationId`, `eventPayload`                                                            |
| `EMIT_NOTIFICATION` | Send a notification through an integration                  | `integrationId`, `targetVar`, `notificationPayload`                                        |
| `EMIT_WEBHOOK`      | Call an external URL or webhook integration                 | `url`, `method`, `payloadTemplate` (optional)                                              |
| `BLOCK`             | Block the request                                           | `reason`                                                                                   |
| `SET_FACT`          | Set a fact to a fixed value                                 | `key`, `value`                                                                             |
| `ADD_TAG`           | Append a tag to a list fact                                 | `tag`, `targetVar`                                                                         |

### MUTATE\_FACT — Percentage

Reduce `payment_amount` by 10%:

```json theme={null}
{
  "type": "MUTATE_FACT",
  "parameters": {
    "refVar": "payment_amount",
    "operator": "SUB",
    "method": "PERCENTAGE",
    "rate": 10
  }
}
```

If `payment_amount` is 200,000 → reduced to 180,000. The change (`-20,000`) is exposed as `payment_amount__delta` in `generatedVariables`.

#### Operators

| Operator | AMOUNT            | PERCENTAGE                                |
| -------- | ----------------- | ----------------------------------------- |
| `ASSIGN` | `refVar = value`  | `refVar = refVar × rate / 100`            |
| `ADD`    | `refVar += value` | `refVar += refVar × rate / 100`           |
| `SUB`    | `refVar -= value` | `refVar -= refVar × rate / 100`           |
| `MUL`    | `refVar *= value` | `refVar *= (rate / 100 + 1)`              |
| `DIV`    | `refVar /= value` | invalid — use `MUL` with the inverse rate |

<Warning>
  `DIV` + `PERCENTAGE` is rejected at draft save time. `DIV` + `value=0` is also rejected.
</Warning>

### MUTATE\_FACT — Fixed Amount

```json theme={null}
{
  "type": "MUTATE_FACT",
  "parameters": {
    "refVar": "payment_amount",
    "operator": "SUB",
    "method": "AMOUNT",
    "value": 5000
  }
}
```

### MUTATE\_FACT — With Rounding

By default, calculator output is preserved at full precision (lossless). Use the optional `rounding` field when you need a fixed scale.

```json theme={null}
{
  "type": "MUTATE_FACT",
  "parameters": {
    "refVar": "payment_amount",
    "operator": "SUB",
    "method": "PERCENTAGE",
    "rate": 13.7,
    "rounding": { "scale": 0, "mode": "FLOOR" }
  }
}
```

`rounding.scale` is an integer in `[0, 16]`. `rounding.mode` is one of `HALF_UP` (default), `HALF_DOWN`, `HALF_EVEN`, `FLOOR`, `CEILING`, `DOWN`, `UP`. Omitting `rounding` keeps full precision — round downstream (e.g. when rendering a currency display) instead of in the engine.

### INCREMENT\_FACT — Percentage

Add 1% of `payment_amount` to `total_point`:

```json theme={null}
{
  "type": "INCREMENT_FACT",
  "parameters": {
    "targetVar": "total_point",
    "refVar": "payment_amount",
    "method": "PERCENTAGE",
    "rate": 1
  }
}
```

If `payment_amount` is 100,000 → `total_point` is incremented by 1,000. The increment is exposed as `total_point__delta` in `generatedVariables`.

<Info>
  `INCREMENT_FACT` only mutates engine-side facts. To synchronize with an external system (e.g. a points service), compose `[INCREMENT_FACT, EMIT_EVENT]` as a chain in the same rule. The engine does not call external systems on behalf of `INCREMENT_FACT` — this preserves audit-grade separation between engine state and external state.
</Info>

### INCREMENT\_FACT — Fixed Amount

```json theme={null}
{
  "type": "INCREMENT_FACT",
  "parameters": {
    "targetVar": "total_point",
    "method": "AMOUNT",
    "value": 500
  }
}
```

### EMIT\_EVENT

Emit a generic event payload to an external integration. The engine validates only that `integrationId` and a non-empty `eventPayload` map are present — payload structure is the integration provider's responsibility.

```json theme={null}
{
  "type": "EMIT_EVENT",
  "parameters": {
    "integrationId": "<your-integration-id>",
    "eventPayload": {
      "couponId": "WELCOME_VIP_2026"
    }
  }
}
```

Domain-specific keys (`couponId`, `ticketId`, `claimId`, etc.) are routed by the integration provider. This makes `EMIT_EVENT` a true generic primitive — usable for coupon issuance, ticket creation, insurance claim submission, or any event-style external call.

### EMIT\_NOTIFICATION

Send a notification through an external integration.

```json theme={null}
{
  "type": "EMIT_NOTIFICATION",
  "parameters": {
    "integrationId": "<your-notification-integration-id>",
    "targetVar": "phone_number",
    "notificationPayload": {
      "channel": "SMS",
      "templateId": "ORDER_CONFIRM_001",
      "variables": { "order_id": "order_id" }
    }
  }
}
```

`targetVar` identifies the recipient fact (e.g. `phone_number`, `email`, `device_token`). `notificationPayload` carries channel, template, and variable mapping for the integration provider — the engine does not interpret these keys.

### BLOCK — Block the Request

```json theme={null}
{
  "type": "BLOCK",
  "parameters": { "reason": "Suspected fraud" }
}
```

### SET\_FACT — Output Variable

<Warning>
  `SET_FACT` is a **literal value setter**, not an expression evaluator. It assigns a fixed value to a key in the output. It does not support arithmetic expressions or references to other facts.
</Warning>

```json theme={null}
{
  "type": "SET_FACT",
  "parameters": { "key": "risk_level", "value": "HIGH" }
}
```

### ADD\_TAG

```json theme={null}
{
  "type": "ADD_TAG",
  "parameters": {
    "tag": "VIP_VERIFIED",
    "targetVar": "user_tags"
  }
}
```

Appends the tag to the list fact. Duplicates are automatically prevented. If `targetVar` is omitted, defaults to `user_tags`.

### EMIT\_WEBHOOK — Basic

```json theme={null}
{
  "type": "EMIT_WEBHOOK",
  "parameters": {
    "url": "https://api.example.com/webhooks/orders",
    "method": "POST"
  }
}
```

Without `payloadTemplate`, the engine sends all input/output facts as the request body (system variables excluded).

### EMIT\_WEBHOOK — With Payload Template

Use `payloadTemplate` to customize the request body. Template variables are resolved at execution time.

```json theme={null}
{
  "type": "EMIT_WEBHOOK",
  "parameters": {
    "url": "<integration-id-or-direct-url>",
    "method": "POST",
    "payloadTemplate": {
      "text": "🔔 Rule {{ruleName}} fired\nCustomer: {{fact.customer_tier}}\nAmount: {{output.payment_amount}}"
    }
  }
}
```

#### Available Template Variables

| Variable         | Description                     | Example Value                                 |
| ---------------- | ------------------------------- | --------------------------------------------- |
| `{{fact.xxx}}`   | Input fact value                | `{{fact.payment_amount}}` → `150000`          |
| `{{output.xxx}}` | Output variable (after actions) | `{{output.payment_amount__delta}}` → `-20000` |
| `{{timestamp}}`  | Execution time (ISO-8601)       | `2026-04-14T05:30:00Z`                        |
| `{{ruleName}}`   | Matched rule name               | `VIP 20% Discount`                            |
| `{{groupName}}`  | Policy group name               | `Payment Policy`                              |
| `{{versionNo}}`  | Executed version number         | `5`                                           |
| `{{xxx}}`        | Direct fact lookup (shorthand)  | `{{customer_tier}}` → `VIP`                   |

<Info>
  **Slack** requires `{"text": "..."}`, **Discord** requires `{"content": "..."}`. Use `payloadTemplate` to match each platform's expected format.
</Info>

<Warning>
  `{{fact.xxx}}` and `{{output.xxx}}` reference the same data map. If an earlier action (e.g., `MUTATE_FACT`) modifies `payment_amount`, both `{{fact.payment_amount}}` and `{{output.payment_amount}}` reflect the modified value.
</Warning>

## Generated Variables

In addition to the mutated facts, the engine exposes per-action change information in `generatedVariables`:

| Pattern              | Meaning                                               | Generated By     |
| -------------------- | ----------------------------------------------------- | ---------------- |
| `{refVar}__delta`    | Net change to `refVar` (`mutated - original`, signed) | `MUTATE_FACT`    |
| `{targetVar}__delta` | Net increment to `targetVar` (always non-negative)    | `INCREMENT_FACT` |

When multiple actions in a rule mutate the same fact, `__delta` reports the cumulative change. Per-action snapshots are available in `executionTraces` for audit drill-down.

## Mutex — Rule-Level Conflict Resolution

Within a single version, rules can belong to a **mutex group** to control how many matching rules fire.

| Field           | Description                                                   |
| --------------- | ------------------------------------------------------------- |
| `mutexGroup`    | A string key grouping related rules (e.g., `"best-discount"`) |
| `mutexMode`     | `NONE` (default), `EXCLUSIVE`, or `MAX_N`                     |
| `mutexStrategy` | `FIRST_MATCH`, `HIGHEST_PRIORITY`, or `MAX_BENEFIT`           |
| `mutexLimit`    | Max number of rules to fire from this group (for `MAX_N`)     |

When `mutexMode` is `EXCLUSIVE`, only the single winning rule fires. When it is `MAX_N`, up to `mutexLimit` rules fire in priority order. Other matching rules in the same mutex group are skipped and logged in the **Decision Trace** with status `BLOCKED` and reason `MUTEX_PRIORITY_LOST` or `MUTEX_LIMIT_REACHED` (see [Decision Trace](#decision-trace)).

## Decision Trace

Every rule evaluation produces a **decision trace entry** explaining whether the rule fired and why. This is the core of LexQ's audit-grade reasoning — every outcome can be traced back to a specific status and reason code.

A decision trace entry has two classifying fields:

* **`status`** — the high-level outcome category
* **`reasonCode`** — the specific reason within that category

### DecisionStatus

| Status         | Meaning                                                             |
| -------------- | ------------------------------------------------------------------- |
| `SELECTED`     | The rule fired and its actions ran                                  |
| `NO_MATCH`     | The rule's condition evaluated to false                             |
| `NOT_SELECTED` | Pre-competition filter (e.g., effective date — reserved for future) |
| `BLOCKED`      | The rule lost mutex or activation group competition                 |
| `ERROR`        | An action or engine error occurred during evaluation                |

### DecisionReasonCode

| ReasonCode               | Meaning                                                                   |
| ------------------------ | ------------------------------------------------------------------------- |
| `FINAL_WINNER`           | The selected rule for this evaluation                                     |
| `EFFECTIVE_DATE_INVALID` | Outside the rule's effective `[from, to]` window (reserved)               |
| `CONDITION_MISMATCH`     | The rule's condition tree did not match the input facts                   |
| `MUTEX_PRIORITY_LOST`    | EXCLUSIVE mutex (limit=1) — another rule had higher priority or score     |
| `MUTEX_LIMIT_REACHED`    | MAX\_N mutex (limit>1) — `mutexLimit` was filled by higher-priority rules |
| `GROUP_PRIORITY_LOST`    | EXCLUSIVE activation group (limit=1) — another rule won by priority       |
| `GROUP_LIMIT_REACHED`    | MAX\_N activation group (limit>1) — `executionLimit` was filled           |
| `ACTION_ERROR`           | An action executor threw during this rule's execution                     |
| `ENGINE_ERROR`           | The engine itself failed before/during this rule                          |

### Status × ReasonCode Mapping

| Status         | Possible ReasonCodes                                                                       |
| -------------- | ------------------------------------------------------------------------------------------ |
| `SELECTED`     | `FINAL_WINNER`                                                                             |
| `NO_MATCH`     | `CONDITION_MISMATCH`                                                                       |
| `NOT_SELECTED` | `EFFECTIVE_DATE_INVALID` (reserved)                                                        |
| `BLOCKED`      | `MUTEX_PRIORITY_LOST`, `MUTEX_LIMIT_REACHED`, `GROUP_PRIORITY_LOST`, `GROUP_LIMIT_REACHED` |
| `ERROR`        | `ACTION_ERROR`, `ENGINE_ERROR`                                                             |

<Info>
  When debugging "why didn't my rule fire?", the decision trace gives you the answer in two steps: the `status` tells you which category of filtering removed the rule, and the `reasonCode` tells you exactly which check rejected it.
</Info>

## Complete Rule Example

```json theme={null}
{
  "name": "VIP 20% Discount",
  "priority": 0,
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      { "type": "SINGLE", "field": "customer_tier", "operator": "EQUALS", "value": "VIP", "valueType": "STRING" },
      { "type": "SINGLE", "field": "payment_amount", "operator": "GREATER_THAN_OR_EQUAL", "value": 100000, "valueType": "NUMBER" }
    ]
  },
  "actions": [
    { "type": "MUTATE_FACT", "parameters": { "refVar": "payment_amount", "operator": "SUB", "method": "PERCENTAGE", "rate": 20 } },
    { "type": "SET_FACT", "parameters": { "key": "discount_applied", "value": true } }
  ],
  "mutexGroup": "best-discount",
  "mutexMode": "EXCLUSIVE",
  "mutexStrategy": "HIGHEST_PRIORITY",
  "isEnabled": true
}
```

## Next Steps

<CardGroup cols={2}>
  <Card title="Fact Definitions" icon="database" href="/guides/fact-definitions">
    Define the input variables your rules expect.
  </Card>

  <Card title="Dry Run" icon="flask-vial" href="/guides/dry-run">
    Test your rules before publishing.
  </Card>
</CardGroup>
