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

# Creating a rule from a policy

> Turn a written policy sentence into a live Tracer rule: map it to fields, create it as a draft, rehearse it on a test account, activate it, and change or retire it later.

export const GMetadata = ({children}) => <Tooltip headline="Metadata" tip="Additional key-value information attached to entities like accounts or transactions — such as external IDs, reference numbers, or department codes." cta="See glossary" href="/en/start-here/glossary">
    {children}
  </Tooltip>;

export const GCEL = ({children}) => <Tooltip headline="CEL (Common Expression Language)" tip="A lightweight expression language for writing business rules — for example, 'if transaction amount > 10000 then REVIEW'. Tracer uses CEL for validation rules." cta="See glossary" href="/en/start-here/glossary">
    {children}
  </Tooltip>;

A compliance officer hands over a sentence: *"decline card-not-present purchases above BRL 5,000 from a device we have not seen before."*

This guide turns that sentence into a rule that evaluates the way the policy reads. You rehearse it where it reaches nobody, then put it live.

**What changes in your operation:** the policy stops living in a ticket and starts living in an endpoint. The person who wrote the sentence can read the rule back. The rehearsal runs through real evaluation instead of a spreadsheet. Each change to the rule leaves an audit event behind it.

<Tip>
  **Who is this guide for?** Risk and fraud analysts authoring rules, and the developers who wire the policy fields into the validation request. Steps 1 and 2 are about the policy. Steps 3 to 8 are API calls.
</Tip>

## Before you start

***

* [ ] Tracer running and reachable, with an API key. See [Getting started](./getting-started.mdx)
* [ ] The <GCEL>CEL</GCEL> variables and the scope model. See [Rules engine](./rule-engine.mdx)
* [ ] A test account id you can send validations for, that no customer traffic uses
* [ ] The policy sentence, written down, with whoever wrote it available for one question

All calls below send the API key as `X-API-Key`.

***

## Step 1: Map the sentence onto fields

***

A rule reads what the validation request carries. Take the sentence apart clause by clause and put each one in the column it belongs to.

| Clause in the policy               | Where the value comes from                                   | Reads as                   |
| ---------------------------------- | ------------------------------------------------------------ | -------------------------- |
| "card ... purchases"               | `transactionType`, a Tracer enum                             | `CARD`                     |
| "above BRL 5,000"                  | `amount` and `asset` on the request                          | `amount`, `asset`          |
| "card-not-present"                 | `subType`, free-form text your integration sets              | `subType`                  |
| "a device we have not seen before" | <GMetadata>metadata</GMetadata>, which your integration sets | `metadata.deviceFirstSeen` |

The first two are fields Tracer defines. The last two are fields **your integration has to send**. Tracer has no opinion about what "card-not-present" or "new device" mean. That is the question to take back to whoever wrote the policy: *which flag in our payload says the device is new?*

<Note>
  `subType` reaches expressions in lower case, so `"card_not_present"` is the form to compare against. If your integration already uses `subType` for something else, carry the entry mode in `metadata` instead and match on that. Both work the same way in an expression.
</Note>

For the full variable list and the fields each context map carries, see [Rules engine](./rule-engine.mdx#expressions).

***

## Step 2: Split the rule between scope and expression

***

Two things in that table (the transaction type and the account) are things Tracer can filter on before an expression runs. Those belong in the rule's `scopes`. The value comparisons belong in the `expression`.

**Scope**, which decides *whether the rule is considered at all*:

```json theme={null}
"scopes": [{ "transactionType": "CARD" }]
```

**Expression**, which decides *whether the rule fires*:

```cel theme={null}
subType == "card_not_present" && amount > 5000 && metadata.deviceFirstSeen == true
```

Read the expression against the sentence: entry mode, threshold, and the device flag. `amount > 5000` is strictly above, so a transaction of exactly `5000.00` does not fire it. Check that against the policy before you go further, because "above" and "from" are different rules.

A rule that reads a metadata key the request does not carry does not match, and the other rules still run. So the expression above needs no presence test for `deviceFirstSeen`. See [Rules engine](./rule-engine.mdx#expressions).

<Warning>
  Do not repeat scope conditions inside the expression. `transactionType == "CARD"` in both places is not wrong. It leaves two places to edit when the policy changes, and the expression is the one that needs a lifecycle round-trip to edit.
</Warning>

***

## Step 3: Create the rule as a draft

***

`POST /v1/rules` creates the rule in `DRAFT`. A draft never reaches evaluation, so nothing you do here reaches traffic.

```bash theme={null}
curl -X POST http://localhost:4020/v1/rules \
  -H "X-API-Key: your-secure-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Deny CNP card purchases above BRL 5,000 from a new device",
    "description": "Compliance policy 2026-14, approved 2026-07-20",
    "expression": "subType == \"card_not_present\" && amount > 5000 && metadata.deviceFirstSeen == true",
    "action": "DENY",
    "scopes": [
      { "transactionType": "CARD", "accountId": "8a1f2c34-5d6e-4a7b-8c9d-0e1f2a3b4c5d" }
    ]
  }'
```

The `accountId` in that scope is your test account. It is what keeps Step 4 off customer traffic. Step 5 takes it out.

A `201` answers with the stored rule:

```json theme={null}
{
  "ruleId": "4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68",
  "name": "Deny CNP card purchases above BRL 5,000 from a new device",
  "description": "Compliance policy 2026-14, approved 2026-07-20",
  "expression": "subType == \"card_not_present\" && amount > 5000 && metadata.deviceFirstSeen == true",
  "action": "DENY",
  "scopes": [
    { "transactionType": "CARD", "accountId": "8a1f2c34-5d6e-4a7b-8c9d-0e1f2a3b4c5d" }
  ],
  "status": "DRAFT",
  "createdAt": "2026-07-31T11:04:12.318Z",
  "updatedAt": "2026-07-31T11:04:12.318Z"
}
```

Tracer preserves the name's case and internal whitespace. It trims leading and trailing whitespace before storage. Rule-name uniqueness is case-sensitive within the context derived from the rule's scopes. So `FraudRule` and `fraudrule` are different names, and the same name can coexist in different contexts.

Take the `ruleId` from the response. That is the handle every call below uses. See [Create a rule](/en/reference/products/tracer/create-rule).

The expression compiles on this call, so an expression that cannot run never becomes a draft. A syntax error answers `0340`, an expression that does not return a boolean answers `0341`, and one whose estimated cost is above `CEL_COST_LIMIT` answers `0342`.

***

## Step 4: Rehearse it on an account nobody else uses

***

The scope you set is what keeps the rehearsal contained. The rule reaches evaluation only for transactions on that one test account. Activation puts it in front of exactly the traffic you send it.

<Steps>
  <Step title="Activate the scoped rule">
    ```bash theme={null}
    curl -X POST http://localhost:4020/v1/rules/4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68/activate \
      -H "X-API-Key: your-secure-api-key"
    ```

    The response comes back with `status: "ACTIVE"` and an `activatedAt`. See [Activate a rule](/en/reference/products/tracer/activate-rule).
  </Step>

  <Step title="Send a transaction the policy should decline">
    ```bash theme={null}
    TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    curl -X POST http://localhost:4020/v1/validations \
      -H "X-API-Key: your-secure-api-key" \
      -H "Content-Type: application/json" \
      -d '{
        "requestId": "c1a7f402-6b93-4d5e-8f10-2a4c6e8b0d31",
        "transactionType": "CARD",
        "subType": "card_not_present",
        "amount": "7500.00",
        "asset": "BRL",
        "transactionTimestamp": "'"$TS"'",
        "account": {
          "accountId": "8a1f2c34-5d6e-4a7b-8c9d-0e1f2a3b4c5d",
          "type": "checking",
          "status": "active"
        },
        "metadata": {
          "deviceFirstSeen": true
        }
      }'
    ```

    See [Validate a transaction](/en/reference/products/tracer/validate-transaction).
  </Step>

  <Step title="Read the decision">
    ```json theme={null}
    {
      "validationId": "b7e3d190-4c25-4e8f-9a16-3d5f7b0c2e41",
      "requestId": "c1a7f402-6b93-4d5e-8f10-2a4c6e8b0d31",
      "decision": "DENY",
      "matchedRuleIds": ["4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68"],
      "evaluatedRuleIds": ["4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68"],
      "reason": "Rule matched with DENY action",
      "totalRulesLoaded": 1,
      "truncated": false,
      "limitUsageDetails": [],
      "processingTimeMs": 6.1,
      "evaluatedAt": "2026-07-31T11:09:44.207Z"
    }
    ```

    Your `ruleId` in `matchedRuleIds` means the rehearsal passed.
  </Step>

  <Step title="Send the cases that should not fire">
    Change one value at a time and repeat the call with a fresh `requestId`: `"amount": "5000.00"` for the boundary, `"deviceFirstSeen": false` for a known device, `"subType": "purchase"` for a card-present sale. Each should come back without your `ruleId` in `matchedRuleIds`.
  </Step>
</Steps>

<Warning>
  Send a new `requestId` for every attempt. The `requestId` is the idempotency key. Repeat one and Tracer answers `200` with the decision it already recorded for that key. The change you just made then looks like it did nothing.
</Warning>

<Note>
  A rehearsal is a real validation. It stores a decision record and writes an audit event, and an `ALLOW` decision consumes the spending limits that cover that account. That is why the test account matters.
</Note>

If your `ruleId` is not in `matchedRuleIds`, work through it in this order. Is the rule `ACTIVE` (`GET /v1/rules/{id}`)? Does the transaction match the scope you set? Do the values you sent satisfy the expression?

***

## Step 5: Put it live

***

Going live means one edit: drop the test account from the scope so the rule applies to the population the policy names.

<Steps>
  <Step title="Stop evaluating the rehearsal version">
    A scope edit does not require `INACTIVE`. Deactivating first makes the switch take effect at a moment you control and records a visible gap in the audit trail. Each instance serves rules from a cache it refreshes on a poll (every 10 seconds by default, `RULE_SYNC_POLL_INTERVAL_SECONDS`). Allow that window for the deactivation to reach every instance. `GET /v1/rules` confirms the stored status, not that every instance has caught up.

    ```bash theme={null}
    curl -X POST http://localhost:4020/v1/rules/4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68/deactivate \
      -H "X-API-Key: your-secure-api-key"
    ```

    Status goes to `INACTIVE`. See [Deactivate a rule](/en/reference/products/tracer/deactivate-rule).
  </Step>

  <Step title="Replace the scope">
    ```bash theme={null}
    curl -X PATCH http://localhost:4020/v1/rules/4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68 \
      -H "X-API-Key: your-secure-api-key" \
      -H "Content-Type: application/json" \
      -d '{ "scopes": [{ "transactionType": "CARD" }] }'
    ```

    `scopes` replaces the whole array. Send every scope object you want the rule to keep. See [Update a rule](/en/reference/products/tracer/update-rule).
  </Step>

  <Step title="Activate">
    ```bash theme={null}
    curl -X POST http://localhost:4020/v1/rules/4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68/activate \
      -H "X-API-Key: your-secure-api-key"
    ```

    Activation reaches the instance that served this call as soon as it commits. When you run several instances behind a load balancer, the others pick the change up on their next rule sync (`RULE_SYNC_POLL_INTERVAL_SECONDS`, default `10`). Deactivation travels the same way. Allow that same window after activation before you treat the rule as enforcing on every instance.
  </Step>

  <Step title="Confirm what is live">
    ```http theme={null}
    GET /v1/rules?status=ACTIVE&transaction_type=CARD&sort_by=updated_at
    X-API-Key: {api_key}
    ```

    The listing answers "which rules enforce on card traffic right now". See [List rules](/en/reference/products/tracer/list-rules). For one rule, `GET /v1/rules/{id}` returns the expression and scopes as stored ([Retrieve a rule](/en/reference/products/tracer/retrieve-rule)). Both report the stored state, not what each instance's cache holds.
  </Step>
</Steps>

<Note>
  A validation loads its rule set once, from the cache of the instance that serves it, when the call starts. A rule that activates while a validation is in flight is not part of that decision. Decisions already recorded do not change when rules change afterwards.
</Note>

***

## Step 6: Know where your rule sits among the others

***

Rules carry no priority field and no ordering to configure. Rules whose scope matches a transaction are evaluated together. The decision comes from the strictest action that fired: a `DENY` rule first, then an exceeded spending limit, then `REVIEW`, then `ALLOW`, then the configured no-match default. The `matchedRuleIds` array carries every rule that matched, whatever action each one holds.

Two consequences for the rule you just wrote:

* **An `ALLOW` rule does not exempt anyone from a `DENY` rule.** If the policy has an exception (VIP customers, a partner merchant), put it inside the `DENY` expression. Add it as one more condition that makes the rule narrower:

  ```cel theme={null}
  subType == "card_not_present" && amount > 5000 && metadata.deviceFirstSeen == true && metadata.customerTier != "vip"
  ```

  Note what that costs: the narrowed rule now reads `metadata.customerTier`, and a request that does not carry that key does not match it.

* **Your rule joins the set every matching transaction evaluates.** `MAX_RULES_PER_REQUEST` caps how many rules one validation evaluates. When the set is larger, the response reports `truncated: true`. See [environment variables](./tracer-environment-variables.mdx).

The precedence table and the reasoning behind it are on the [Rules engine](./rule-engine.mdx#evaluation-pattern) page.

***

## Step 7: Change the rule when the policy changes

***

What you do depends on the field, not on how the rule performs.

| What changed                                                                    | How                                                                                      |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| The threshold, the entry mode, the device flag — anything inside the expression | Deactivate, then `POST /v1/rules/{id}/draft`, then `PATCH` the expression, then activate |
| `DENY` becomes `REVIEW`                                                         | `PATCH /v1/rules/{id}` with the new `action`                                             |
| Name, description, or scopes                                                    | `PATCH /v1/rules/{id}`                                                                   |

<Warning>
  The `expression` accepts an edit only while the rule is `DRAFT`. Sending one to a rule in another status answers `422` with error code `0351`. Deactivating is not enough on its own, because `INACTIVE` is not `DRAFT`. `POST /v1/rules/{id}/draft` is the step people miss. See [Draft a rule](/en/reference/products/tracer/draft-rule).
</Warning>

A `PATCH` is stored when it answers, and reaches evaluation on the next rule sync. When the change matters to the minute, deactivate first and activate again after. That sequence also puts a visible gap in the audit trail where the rule did not enforce. A reviewer will look for that gap.

The lifecycle is a closed set of moves: `DRAFT` activates or is deleted. `ACTIVE` deactivates. `INACTIVE` goes back to `DRAFT`, back to `ACTIVE`, or is deleted. `DELETED` is the end. Anything else answers `422` with error code `0349`, including a request to draft a rule that is still `ACTIVE`.

***

## Step 8: Retire or delete the rule

***

**To stop enforcing without losing anything**, deactivate. The rule keeps its expression, its scopes, and its history. It no longer reaches evaluation, and `POST /v1/rules/{id}/activate` brings it back. That is the move for a policy that is suspended, seasonal, or under review.

**To remove it**, delete. Deactivate first, because you cannot delete a rule in `ACTIVE`:

```http theme={null}
DELETE /v1/rules/4d9c2e70-8b1a-4f36-9c07-5e2a1b3d4f68
X-API-Key: {api_key}
```

A `204` answers on success. See [Delete a rule](/en/reference/products/tracer/delete-rule).

What deleting removes:

* The rule stops answering on `GET /v1/rules/{id}`, which returns `404` with error code `0347` afterwards.
* It no longer appears in `GET /v1/rules`, and `DELETED` is not a value the `status` filter accepts.
* `DELETED` is the end of the lifecycle. No endpoint moves a rule out of it. A deleted rule comes back only as a new rule you create again.

What deleting leaves behind:

* The audit trail keeps the rule's lifecycle, and the `RULE_DELETED` event carries the definition (name, description, expression, action, scopes) as it stood at deletion. See [Audit and compliance](./audit-compliance.mdx).
* Decisions the rule produced keep its `ruleId` in `matchedRuleIds`. A denial from six months ago still names it. See [Reviewing a denied transaction](./reviewing-a-denied-transaction.mdx).
* The name becomes available again for a new rule in the same context.

<Warning>
  Deactivate, then read the audit trail, then delete. Deactivating is reversible in one call and deleting is not reversible at all, so there is no reason to skip the intermediate state.
</Warning>

***

## Common pitfalls

***

<Warning>
  **What usually goes wrong turning a policy into a rule:**

  * **"The rule is ACTIVE but matches nothing."** Check the field the policy leans on hardest. A rule reading `metadata.deviceFirstSeen` matches nothing if your integration never sends that key. The rule is correct and the payload is incomplete.
  * **"It fired on a transaction the policy exempts."** An `ALLOW` rule does not override a `DENY`. Put the exemption inside the `DENY` expression (Step 6).
  * **"My second rehearsal returned the first decision."** `requestId` is the idempotency key. Send a new UUID per attempt.
  * **"PATCH rejected my expression with 422."** The rule was not in `DRAFT`. Move it there first (Step 7).
  * **"The name I sent is not the name I get back."** Tracer trims only leading and trailing whitespace. It preserves case and internal whitespace. Reference the rule by `ruleId`.
</Warning>

### Error codes

| Code                     | Status | What to change                                                                               |
| ------------------------ | ------ | -------------------------------------------------------------------------------------------- |
| `0340`                   | 400    | The expression does not parse as CEL                                                         |
| `0341`                   | 400    | The expression does not return a boolean — `amount > 5000`, not `amount`                     |
| `0342`                   | 422    | The expression's estimated cost is above `CEL_COST_LIMIT`                                    |
| `0347`                   | 404    | No rule has that `ruleId`, or it was deleted                                                 |
| `0349`                   | 422    | The lifecycle does not allow that move — for example deleting an `ACTIVE` rule               |
| `0351`                   | 422    | An expression edit on a rule that is not `DRAFT`                                             |
| `0353` / `0355` / `0357` | 400    | `name`, `expression`, or a valid `action` is missing                                         |
| `0354` / `0356` / `0359` | 400    | `name` above 255, `expression` above 5000, or `description` above 1000 characters            |
| `0358`                   | 400    | A scope object with no field set — omit `scopes` entirely for a global rule, never send `{}` |
| `0360`                   | 400    | More than 100 scope objects on one rule                                                      |
| `0441`                   | 409    | Another rule in the same context already holds that name                                     |
| `0065`                   | 400    | The id in the path is not a UUID                                                             |
| `0082`                   | 400    | A filter on `GET /v1/rules` carries a value the endpoint does not accept                     |

The complete list is in the [Tracer error list](/en/reference/products/tracer/tracer-error-list).

***

## Quick reference

***

| Step               | Method | Endpoint                    |
| ------------------ | ------ | --------------------------- |
| Create as a draft  | POST   | `/v1/rules`                 |
| Start evaluating   | POST   | `/v1/rules/{id}/activate`   |
| Rehearse or verify | POST   | `/v1/validations`           |
| Stop evaluating    | POST   | `/v1/rules/{id}/deactivate` |
| Reopen for editing | POST   | `/v1/rules/{id}/draft`      |
| Change fields      | PATCH  | `/v1/rules/{id}`            |
| Read one rule      | GET    | `/v1/rules/{id}`            |
| See what is live   | GET    | `/v1/rules`                 |
| Remove             | DELETE | `/v1/rules/{id}`            |
