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

# Writing Rules

> Define when and how participants earn and spend

Rules are the core logic layer in Scrip. Each rule is a condition/action pair: if the condition matches, the actions execute.

## Rule Structure

```json theme={null}
{
  "name": "Purchase Reward",
  "program_id": "program-uuid",
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "order": 1000,
  "actions": [
    {"type": "CREDIT", "asset_id": "points-uuid", "amount": "${{ event.amount * 10 }}"}
  ]
}
```

In action fields, the `${{ }}` wrapper marks a value as a CEL expression (see [Rule Actions: Dynamic action fields](/guides/rule-actions#dynamic-action-fields)).

| Field                       | Required | Description                                                                      |
| --------------------------- | -------- | -------------------------------------------------------------------------------- |
| `program_id`                | Yes      | Which program this rule belongs to                                               |
| `name`                      | Yes      | Display name (1-255 characters)                                                  |
| `condition`                 | Yes      | A CEL expression that must return `true` for actions to fire                     |
| `actions`                   | Yes      | What to do when the condition matches (see [Rule Actions](/guides/rule-actions)) |
| `description`               | No       | Additional context (max 1000 characters)                                         |
| `order`                     | No       | Evaluation order. Lower values evaluate first. Auto-assigned if omitted.         |
| `stop_after_match`          | No       | If `true`, skip all subsequent rules when this one matches                       |
| `active_from` / `active_to` | No       | Time window for the rule (RFC 3339)                                              |
| `budgets`                   | No       | Asset-level spending caps for this rule. See [Budgets](#budgets).                |
| `status`                    | No       | `ACTIVE` (default) or `SUSPENDED`                                                |

## Conditions

Conditions are written in [CEL (Common Expression Language)](https://github.com/google/cel-spec), a simple expression language for evaluating boolean conditions against event data and participant state.

```javascript theme={null}
// Simple event match
event.type == "purchase"

// Combine multiple checks
event.type == "purchase" && event.amount > 100.0 && !("vip" in participant.tags)

// Counter threshold (dot-access shorthand; a missing counter defaults to 0)
participant.counter.purchase_count >= 10
```

See [CEL Expressions](/guides/cel-expressions) for the full reference, including available variables, helper functions, and common patterns.

## Evaluation Order

Rules evaluate in `order` ASC (lower values first). Use order to create priority tiers:

| Order | Use Case                     |
| ----- | ---------------------------- |
| 100   | VIP overrides, special cases |
| 500   | Category-specific bonuses    |
| 1000  | Default rules                |
| 2000  | Fallback / catch-all rules   |

When `order` is omitted, it's auto-assigned with a value 10 above the current highest order in the program, leaving gaps for later insertion.

<Warning>
  No two active rules in the same program can share the same `order` value. The API rejects a rule if its `order` conflicts with an existing active rule.
</Warning>

### `stop_after_match`

When a rule with `stop_after_match: true` matches and its actions execute, no subsequent rules are evaluated. Use this for mutually exclusive rewards:

```json theme={null}
{
  "name": "VIP Double Points",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == 'purchase' && 'vip' in participant.tags",
  "actions": [
    {"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount * 10 }}"}
  ]
}
```

```json theme={null}
{
  "name": "Standard Points",
  "order": 200,
  "condition": "event.type == 'purchase'",
  "actions": [
    {"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount * 2 }}"}
  ]
}
```

VIPs get 10x (the first rule matches and stops). Everyone else gets 2x (the first rule skips, the second matches).

## Time-Windowed Rules

Use `active_from` and `active_to` to schedule rules for specific periods. Outside the window, the rule is skipped during evaluation.

Time windows are checked against the event's `event_timestamp`, the same value CEL sees as `now`, not the wall-clock time at processing. `active_from` is inclusive and `active_to` is exclusive. A matching rule outside its window is recorded as a skipped evaluation with reason `OUTSIDE_TIME_WINDOW`.

* Delayed processing, retries, and replays make the same decision because they read the same timestamp.
* Event timestamps are supplied by the caller, so a backdated event (a historical import, for example) can land inside a window that has already closed on the calendar and still fire the rule.

This is useful for layering a promotional rule on top of an existing base rule. Consider a base rule that always gives 1x points:

```json theme={null}
{
  "name": "Standard Points",
  "order": 1000,
  "condition": "event.type == 'purchase'",
  "actions": [
    {"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount }}"}
  ]
}
```

To run a holiday promo that gives an additional 1x during December, add a second rule with a time window:

```json theme={null}
{
  "name": "Holiday Bonus Points",
  "order": 500,
  "active_from": "2025-12-01T00:00:00Z",
  "active_to": "2026-01-01T00:00:00Z",
  "condition": "event.type == 'purchase'",
  "actions": [
    {"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount }}"}
  ]
}
```

Purchases with a December `event_timestamp` fire both rules and earn 2x total. Events stamped January 1st or later skip the promo rule (`active_to` is exclusive) and the base rule continues on its own.

## Budgets

Budgets cap how much a rule can issue per asset over a given period. When the budget is exhausted, the rule still matches but all of its actions are skipped for that evaluation. You might use a budget to limit a referral rule to \$10,000/month or cap a promotional rule at 50,000 points total.

A budget is a single shared pool, not a per-participant allowance. The `limit` applies to total issuance for the rule across every participant in the program. A `10000` point budget means the rule issues 10,000 points combined to all participants, not 10,000 per participant. Consumption is tracked per `(rule, asset)`, so each entry in the `budgets` array is one program-wide cap.

To cap how much an individual participant can earn, don't use a budget. Instead, track the participant's earnings in a counter and gate the rule on it. Add a `COUNTER` action that increments a per-participant counter by the credited amount, then guard the rule with a condition that checks the counter against the cap:

```json theme={null}
{
  "name": "Referral Bonus (500 point lifetime cap per participant)",
  "condition": "event.type == 'referral' && participant.counter.referral_points < 500.0",
  "actions": [
    {"type": "CREDIT", "asset_id": "points-uuid", "amount": "100"},
    {"type": "COUNTER", "key": "referral_points", "value": "100"}
  ]
}
```

Here a participant stops earning referral points once their `referral_points` counter reaches 500, regardless of how many other participants the rule has paid out. A budget and a counter cap are independent: combine both to cap per-participant earning and total program spend at the same time.

Budgets are defined inline on the rule as an array of per-asset limits:

```json theme={null}
{
  "name": "Referral Bonus",
  "condition": "event.type == 'referral'",
  "actions": [
    {"type": "CREDIT", "asset_id": "points-uuid", "amount": "100"}
  ],
  "budgets": [
    {
      "asset_id": "points-uuid",
      "limit": "10000",
      "schedule_type": "CRON",
      "cron_expression": "0 0 1 * *"
    }
  ]
}
```

This rule credits 100 points per referral, but stops issuing after 10,000 points in a calendar month. On the 1st of each month, the budget resets automatically.

| Field             | Required                           | Description                                                                                                                            |
| ----------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `asset_id`        | Yes                                | The asset this budget constrains. Must be linked to the rule's program.                                                                |
| `limit`           | Yes                                | Maximum amount that can be issued before the budget is exhausted                                                                       |
| `schedule_type`   | No                                 | `CRON` or `INTERVAL`. Omit for a lifetime budget that never resets on its own; once exhausted it stays exhausted until manually reset. |
| `cron_expression` | When `schedule_type` is `CRON`     | Standard cron expression for the reset schedule (e.g., `"0 0 1 * *"` for monthly)                                                      |
| `interval`        | When `schedule_type` is `INTERVAL` | Duration between resets (e.g., `"30d"`). Accepts `h`, `d`, and `w` units. Timer starts from when the budget is created.                |

### Schedule Types

A lifetime cap omits `schedule_type`:

```json theme={null}
{"asset_id": "...", "limit": "50000"}
```

`CRON` resets on a calendar-aligned schedule, tied to the same wall-clock times regardless of when the rule was created:

```json theme={null}
{"asset_id": "...", "limit": "10000", "schedule_type": "CRON", "cron_expression": "0 0 1 * *"}
```

`INTERVAL` resets after a fixed duration, timed from budget creation and restarting after each reset:

```json theme={null}
{"asset_id": "...", "limit": "500", "schedule_type": "INTERVAL", "interval": "30d"}
```

### Consumption

Each time the rule fires a credit action, the amount is checked against the budget and added to a single `consumed` total shared by all participants. If the consumed amount plus the new amount would exceed the limit, all of the rule's actions are rolled back and the evaluation is recorded as skipped with reason `BUDGET_EXCEEDED`. Consumption is atomic, so concurrent events can't overspend, even when different participants trigger the rule at the same time.

The response for any rule includes the current budget state:

```json theme={null}
{
  "budgets": [
    {
      "asset_id": "points-uuid",
      "limit": "10000.00",
      "consumed": "4500.00",
      "schedule_type": "CRON",
      "cron_expression": "0 0 1 * *",
      "next_reset_at": "2026-03-01T00:00:00Z"
    }
  ]
}
```

### Resetting a Budget

Scheduled budgets reset automatically when `next_reset_at` arrives. You can also reset a budget manually:

```bash theme={null}
POST /v1/rules/{id}/reset-budget?asset_id={asset-uuid}
```

This sets `consumed` back to zero and advances `next_reset_at` to the next scheduled reset. For lifetime budgets, the consumed amount resets but no future reset is scheduled.

### Updating Budgets

Budgets are updated as part of the rule. Include the full `budgets` array in your update request and it replaces the previous one. Omitting the field leaves budgets unchanged.

```bash theme={null}
PATCH /v1/rules/{id}
{
  "budgets": [
    {"asset_id": "points-uuid", "limit": "20000", "schedule_type": "CRON", "cron_expression": "0 0 1 * *"}
  ]
}
```

To remove all budgets from a rule, send an empty array:

```bash theme={null}
PATCH /v1/rules/{id}
{"budgets": []}
```

<a id="counter-evaluation-behavior" />

## State Snapshot Evaluation Behavior

When an event is processed, Scrip snapshots participant state, program state, and group state at the start. All rule conditions and dynamic action expressions within that event evaluate against this snapshot, not the live database. That means if Rule A increments a counter, adds a tag, sets an attribute, or assigns a tier, Rule B still sees the original value even though it evaluates after Rule A.

This applies to counters, tags, attributes, tiers, program state, and group state. It also applies to dynamic action expressions: `CREDIT` / `DEBIT` / `HOLD` / `RELEASE` / `FORFEIT` amounts, `COUNTER` values, `SET_ATTRIBUTE` values, and `reference_id` expressions all use the same event-start snapshot. State actions write durable updates during processing, but those updates become inputs for future events, not for later rules (or later action expressions) in the same event.

### Example

```
Rule 1 (order: 100): COUNTER "spend" += event.amount
Rule 2 (order: 200): condition: participant.counter.spend >= 1000
```

If `spend` is 900 and `event.amount` is 200:

* Rule 1 executes, updating the counter to 1100 in the database
* Rule 2 evaluates against the snapshot value (900), so it does not match

The same holds for other state types. If Rule A fires a `TAG` action that adds `vip`, a Rule B condition that checks `"vip" in participant.tags` still evaluates against the pre-event tag set. The tag is visible on the next event.

### Threshold Crossing Pattern

To detect when a counter crosses a threshold during an event, check the pre-event value plus the event amount:

```javascript theme={null}
// Detect the event that crosses the $1000 threshold
participant.counter.spend < 1000.0 &&
(participant.counter.spend + event.amount) >= 1000.0
```

For tag, attribute, or tier dependencies within the same event, put the dependent action in the same rule or trigger it on a later event (for example, with [`SCHEDULE_EVENT`](/guides/rule-actions#schedule_event)).

See [CEL Expressions](/guides/cel-expressions#common-patterns) for more patterns including milestones, date ranges, and capped bonuses.

## Rule Status

| Status      | Behavior                                                                            |
| ----------- | ----------------------------------------------------------------------------------- |
| `ACTIVE`    | Evaluates on every event                                                            |
| `SUSPENDED` | Disabled. Skipped during evaluation. Can be reactivated.                            |
| `ARCHIVED`  | Soft-deleted. Excluded from evaluation and listings unless `include_archived=true`. |

## Updating rules under live traffic

Scrip reads a program's rule definitions at processing time: the moment a worker picks up the event, not when you submit it. Each queued event is evaluated against whatever the rules say at the instant it is processed.

This matters when you change a rule (its condition, actions, amount, order, or status) while events are still in flight:

* Events already processed keep the result from the previous definition.
* Events not yet processed use the new definition.
* A batch that spans your change can split across both versions, because workers process queued events independently and at slightly different times.

This behavior is intentional and consistent: the newest active definition wins as soon as it goes live. That is what you want for changes like a midnight promotional rate or a fast fraud-response tweak. Scrip does not snapshot the rule as it was when the event was ingested.

To see which version applied to a given event, read its rule evaluations. Each entry in the `rule_evaluations` array includes a `rule_history_id` that links to the exact rule version active when the event was evaluated:

```bash theme={null}
GET /v1/events/{id}
```

If you need every event in a batch to use a single definition, stop submitting affected events and let the queue drain before you make the change.

## Validation and Simulation

### Save-time validation

Rule create and update validate every action expression, not only the condition. Template syntax errors, CEL compile errors, and references to unknown participant fields, tier keys, or asset symbols are rejected with a `400` that names the field path (for example, `actions[0].amount`). A static `amount` that is zero or negative is also rejected.

The `condition` field is always CEL and never uses the `${{ }}` wrapper. A condition containing `${{` is rejected at save with `condition is always a CEL expression; write it without the ${{ }} wrapper`, so the GitHub Actions habit of wrapping expressions fails loudly instead of as a cryptic parse error. The same check applies to automation `participant_filter` and `guard_condition`.

Create, update, and validate responses include a non-blocking `warnings` array. Warnings cover both the condition and action expressions, and include `deprecated_alias` when an expression uses the legacy `state` variable: `CEL variable "state" is deprecated; use "participant" instead.` Rules with warnings still save and evaluate.

### Validate a condition

Check a condition, and optionally draft actions, before creating a rule:

```bash theme={null}
POST /v1/rules/validate
{"condition": "event.type == \"purchase\" && event.amount > 0"}
```

Returns whether the expression is valid. Pass an optional `program_id` to check tier keys and asset symbols against that program's configuration and receive vocabulary warnings, and an optional `actions` array to run the same save-time checks on draft actions. `event.*` field references are not verified because events are schemaless: a misspelled event field saves fine and then silently never matches. To see which event fields a program's rules currently depend on, call the [event references endpoint](/api-reference/rules/get-rule-event-references) and check your event sender supplies each one.

### Simulate a rule

Test a rule against sample data without persisting any changes:

```bash theme={null}
POST /v1/rules/{id}/simulate
{
  "event": {"type": "purchase", "amount": 75.0},
  "participant_state": {
    "tags": ["vip"],
    "counters": {"purchase_count": 9},
    "attributes": {"region": "US"}
  }
}
```

The `participant_state` field is optional. The response includes whether the condition matched and, for each action, the evaluated result (resolved amounts, projected counter values, etc.).

To simulate against a real participant's current state instead of mocking it, pass `participant_id` (mutually exclusive with `participant_state`):

```bash theme={null}
POST /v1/rules/{id}/simulate
{
  "event": {"type": "purchase", "amount": 75.0},
  "participant_id": "550e8400-e29b-41d4-a716-446655440000"
}
```

This loads the participant's live tags, counters, attributes, tiers, identity fields, and ledger balances, the same context production evaluation sees, so threshold and balance rules can be tested faithfully. If the rule's condition reads participant state and you supply neither field, the response includes a non-blocking `missing_participant_context` warning and the condition evaluates against empty defaults.

Simulation enforces the same runtime constraints as live execution: asset action amounts must resolve to positive values (zero or negative amounts fail in the action result), and event numbers or amount results beyond the 2^53 precision boundary are rejected. A clean simulation is a faithful pre-flight check, more than a syntax pass.
