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

# Rules, recipes, and flows

> Author your own fraud logic in Watch - the conditions Prelude evaluates, how they are scored, and how you attach them to the moments you want to guard.

<Note>
  **Authoring is in beta.** The request and response shapes may still change, and the Management API is enabled per account. [Contact us](mailto:support@prelude.so) to turn it on.
</Note>

[Predict](/watch/v2/api-reference/predict-outcome) answers with Prelude's own model. **Eval** answers with logic you wrote. This page covers the three objects you author to get there, and the language you write them in.

## The three objects

```text theme={null}
flow            "signup"              ← what your product references when it evaluates
 └─ recipe      "proxy_abuse"         ← a threshold, and rules with weights
     └─ rule    "residential_burst"   ← one condition over the signals
```

| Object     | What it is                                                  | Answers                                            |
| ---------- | ----------------------------------------------------------- | -------------------------------------------------- |
| **Rule**   | One boolean condition over the signals Prelude resolves     | Did this condition hold?                           |
| **Recipe** | Weighted rules scored against a threshold                   | Is this identifier a problem, and how sure are we? |
| **Flow**   | The moment you are guarding, and the recipes that run there | What should happen at this gate?                   |

Each layer is reusable by the one above: a rule can be scored by several recipes, and a recipe can be referenced by several flows. Build them bottom-up - [rule](/watch/v2/api-reference/management/rules/create-rule), then [recipe](/watch/v2/api-reference/management/recipes/create-recipe), then [flow](/watch/v2/api-reference/management/flows/create-flow) - because each references the layer below by identifier and the reference is checked when you save.

Only the flow identifier reaches your application. Everything else can be re-tuned without a deploy on your side.

## Scoring

A recipe sums the weights of the rules that **triggered** and flags when the total reaches its `threshold`. A rule that did not trigger or that could not run do not affect the score.

```text theme={null}
threshold 70
  residential_burst   weight 40   triggered      → 40
  headless_browser    weight 30   triggered      → 70   ≥ 70, FLAG
  device_reuse        weight 50   not evaluated  →  0
```

### Rules that could not run

A rule reading a signal that never arrived reports `NOT_EVALUATED` rather than being scored as false. This is the distinction the whole model rests on: **missing is not false**. A recipe with at least one such rule reports `partial_evidence`, and a `PASS` carrying it is weaker evidence than a `PASS` without it.

The usual cause is a request that did not carry device or network signals. Passing `dispatch_id` from a [Frontend SDK](/introduction/frontend-sdks/introduction) fills in most of what rules read.

### Preempting rules

Set `preempts` on a rule association to make that rule's outcome the recipe's verdict, bypassing the score - for a condition that is conclusive on its own.

The alternative, a weight above every threshold the recipe might later be given, is a statement about the recipe's arithmetic rather than about the rule, and it silently stops being true when the threshold moves.

The weight still applies and the score is still reported; an evaluation references the preempting rule in `determined_by`. That field is the only thing accounting for a recipe reporting a score under its threshold and flagging anyway.

### Status

`status` decides whether a recipe evaluates at all, and whether its verdict counts.

| Status     | Evaluates | Counts | Use for                                   |
| ---------- | --------- | ------ | ----------------------------------------- |
| `DRAFT`    | No        | -      | Assembling it                             |
| `SHADOW`   | Yes       | No     | Watching a threshold against live traffic |
| `LIVE`     | Yes       | Yes    | In service                                |
| `INACTIVE` | No        | -      | Taken back out                            |

Go through `SHADOW`. A threshold picked without seeing live traffic is a guess, and shadow scoring is the only way to find out what it would have done before it decides anything.

A flow referencing a `DRAFT` or `INACTIVE` recipe runs its other recipes and reports nothing for that one. It was not asked, which is not the same as having passed.

## Writing an expression

A rule's `expression` is [CEL](https://cel.dev) and must return a boolean. It is compiled before it is stored, so an undeclared name or a type mismatch is refused at authoring time rather than becoming a rule that never fires.

An expression reads three kinds of name:

|                    | Written as    | Comes from                                       |
| ------------------ | ------------- | ------------------------------------------------ |
| Prelude signals    | a bare name   | What Prelude resolves about the evaluated moment |
| Request attributes | `attr.<key>`  | The evaluation request, declared by the recipe   |
| Recipe parameters  | `param.<key>` | The recipe's own configuration                   |

The two namespaced kinds are your own names, and behave the same way in any recipe declaring them:

```javascript theme={null}
// A plan tier the recipe was told to treat as higher risk
attr.plan_tier == "free"

// A country list the recipe was configured with
param.blocked_countries.split(",").exists(c, c == attr.signup_country)
```

Beyond CEL's own operators, expressions can use the string, list, set, regex and network extensions - `split`, `contains`, `matches`, `sets.intersects`, `net.inCIDR`, `ip()`, and the rest of those libraries.

A comparison against a value that never arrived does not evaluate to `false`; it makes the whole rule `NOT_EVALUATED`. Write the condition you mean and let the engine report absence - do not try to encode a fallback.

## Signals

The complete list of signals available to rules you author is documented in the customer dashboard, available for you to consult once you're logged in.

## Attributes and parameters

Rules can read two kinds of value the catalog knows nothing about. A recipe declares both; a rule references them namespaced.

|                  | `attr.<key>`                                               | `param.<key>`                                     |
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------- |
| Declared in      | The recipe's `attributes`                                  | The recipe's `parameters`                         |
| Value comes from | The evaluation request                                     | The recipe itself                                 |
| Changes          | Per request                                                | Only when the recipe is replaced                  |
| If absent        | Missing evidence - rules reading it report `NOT_EVALUATED` | Cannot be absent; the recipe would not have saved |
| Good for         | Plan tier, account age, cart total                         | A country list, a limit, a tuning constant        |

Keys are lower snake\_case, at most 64 characters, declared **without** the namespace, and at most 32 of each per recipe. Values are strings - the only type either supports today, so compare as text or `split` your way to a list.

The namespaces are reserved, so a signal Prelude adds later can never collide with a key you declared.

Parameters are what let one rule serve two recipes tuned differently: the same `blocked_countries` rule, strict at checkout and lenient at signup, is one rule and two recipes.

## Rules Prelude maintains

Your recipes can score over [managed rules](/watch/v2/api-reference/management/rules/list-managed-rules) - Prelude's own, shared by every customer - alongside yours. Name one in `rules` with a weight, exactly as you would your own.

They are returned without expressions, and with a description in place of a name. In an evaluation a managed rule reports its identifier, weight and outcome, and `blocked_by: "missing_data"` rather than identifying the signal it needed.

A managed rule reads signals only. It can't reference `attr.` or `param.` names, and a recipe referencing one that would is refused - a shared rule pinned to one customer's recipe is no longer shared.

## Changing things safely

References are checked in both directions, which fixes the order you work in.

**Creating**, work upward: a recipe referencing a rule that doesn't exist is refused, and so is a flow referencing a missing recipe.

**Deleting**, work downward: a rule a recipe scores over answers `409 rule_in_use`, and a recipe a flow references answers `409 recipe_in_use`. Both list what still refers to them in `details`. Nothing refers to a flow, so a flow deletes freely.

This is refused rather than cascaded because a recipe reads its rules all or nothing. One dangling identifier fails the read, and with it every evaluation of every flow that recipe belongs to - a bad delete would take out the gate, not just the rule.

To stop a recipe deciding without unpicking anything, replace it with `status: "INACTIVE"`. The flow keeps working and the recipe stops evaluating.

## Putting it together

```bash theme={null}
# 1. A rule
curl -X POST https://api.prelude.dev/v2/watch/management/rules \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"free_plan_signup","expression":"attr.plan_tier == \"free\""}'
# → {"id":"rul_01jc0t6fwwfgfsq1md24mhyztj", ...}

# 2. A recipe scoring it, in shadow
curl -X POST https://api.prelude.dev/v2/watch/management/recipes \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"signup_risk","status":"SHADOW","threshold":70,
       "rules":[{"rule_id":"rul_01jc0t6fwwfgfsq1md24mhyztj","weight":40}],
       "attributes":["plan_tier"]}'
# → {"id":"rcp_01jd1u7gxxghgtr2ne35nizauk","catalog_version":1, ...}

# 3. A flow for the moment you guard
curl -X POST https://api.prelude.dev/v2/watch/management/flows \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"signup","recipes":["rcp_01jd1u7gxxghgtr2ne35nizauk"]}'
# → {"id":"flo_01je2v8hyyhihus3of46ojabvl", ...}

# 4. Evaluate it
curl -X POST https://api.prelude.dev/v2/watch/eval \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"flow_id":"flo_01je2v8hyyhihus3of46ojabvl",
       "target":{"type":"phone_number","value":"+30123456789"}}'
```

Watch the shadow verdicts, settle on a threshold, then [replace the recipe](/watch/v2/api-reference/management/recipes/replace-recipe) with `status: "LIVE"`. The flow identifier never changed, so nothing in your application has to.
