> ## 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/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/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, rehearses it where it reaches nobody, and puts 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, and 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 `currency` on the request                       | `amount`, `currency`       |
| "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, but 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 is not evaluated, 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 stores the rule name in a normalized form, so the `name` it returns can differ from the string you sent. Take the `ruleId` from the response — that is the handle every call below uses. See [Create a rule](/en/reference/tracer/create-rule).

The expression is compiled 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 is considered only for transactions on that one test account, so activating it 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/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",
        "currency": "BRL",
        "transactionTimestamp": "'"$TS"'",
        "account": {
          "accountId": "8a1f2c34-5d6e-4a7b-8c9d-0e1f2a3b4c5d",
          "type": "checking",
          "status": "active"
        },
        "metadata": {
          "deviceFirstSeen": true
        }
      }'
    ```

    See [Validate a transaction](/en/reference/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` is the rehearsal passing.
  </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. `requestId` is the idempotency key: repeat one and Tracer answers `200` with the decision it already recorded for that key, so the change you just made will look 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, and 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`), so 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/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/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 "what is enforcing on card traffic right now" — see [List rules](/en/reference/tracer/list-rules). For one rule, `GET /v1/rules/{id}` returns the expression and scopes as stored ([Retrieve a rule](/en/reference/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, and 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, and 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. `matchedRuleIds` 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 — the exception belongs inside the `DENY` expression, as one more condition that makes it 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 is doing.

| 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/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 was not enforcing, which is what a reviewer will look for.

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, stops being evaluated, 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 — and only after deactivating, because a rule in `ACTIVE` cannot be deleted:

```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/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 nothing matches."** 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 stores names in a normalized form. 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/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}`            |
