> ## 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.

### Overriding rules

Set `overrides` 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 overriding rule in `determined_by`. That field is the only thing accounting for a recipe reporting a score under its threshold and flagging anyway.

#### Conflicting overrides

When overriding rules with different verdicts both trigger, `PASS` wins over `FLAG`. The order of `rules` does not change the result, and the score is not consulted. Rules that did not trigger, or that report `NOT_EVALUATED`, take no part.

```text theme={null}
threshold 70
  known_fraud_ring    weight 80   overrides FLAG   triggered
  verified_customer   weight  0   overrides PASS   triggered   → PASS
```

`determined_by` names the rule that set the verdict. If several overriding rules with that same verdict trigger, it names the first one to trigger, in the order of `rules`; rules that need a lookup are run after the others.

The override applies to its own recipe only. In a flow, a `LIVE` recipe that flags still flags the evaluation, whatever another recipe's `PASS` override decided.

### 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 four kinds of name, each under its own namespace:

| | Written as | Comes from |
| - | - | - |
| Prelude signals | `signal.<name>` | What Prelude resolves about the evaluated moment, the front-end SDK, and the SDK signals you send |
| Your own signals | `signal.ext.<key>` | The signals you send under `signals.ext` in the evaluation request |
| The target | `target.phone_number`, `target.email_address` | The evaluation request |
| Recipe parameters | `param.<key>` | The recipe's own configuration |

The namespace is what tells a name you can read apart from CEL's own syntax, and it is required: a signal written without it does not resolve.

```javascript theme={null}
// A signal Prelude resolved about the device
signal.hardware_manufacturer == "apple"

// A plan tier your application sent with the request
signal.ext.plan_tier == "free"

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

// The end user the evaluation is about
target.email_address.endsWith("@example.com")
```

The target is the only one of the four that is always there in some form, since an evaluation names at least one of the two halves. The half a request omits is absent like any other value: rules reading it report `NOT_EVALUATED`.

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 signals available to rules you author are served by [List signals](/watch/v2/api-reference/management/rules/list-signals), each under the identifier you write it as, with its type and what it means. Prelude publishes the catalog progressively, so read it rather than holding a copy.

A `signal.` name the listing does not carry is not a name your rules can read, except for your own signals under `signal.ext.`. An expression referencing one is refused with ``Unsupported signal `signal.<name>` `` - the same answer a misspelling gets, so the error never tells you whether the name exists.

## Your own signals and parameters

Rules can read two kinds of value the catalog knows nothing about, each under its own namespace.

| | `signal.ext.<key>` | `param.<key>` |
| - | - | - |
| 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`. An empty value counts as absent | 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 |

A parameter is a name **and** a value, so you set it on the recipe, in `parameters`. One of your own signals is a name alone: write `signal.ext.plan_tier` in a rule and that is the whole declaration - nothing to add to the recipe, and nothing to keep in step with it. Send a value for it as `plan_tier` under `signals.ext` when you evaluate.

To have a rule read your own value for something Prelude also resolves, such as the end user's IP address, send it as one of your own signals (for example `signals.ext.ip`) and read it as `signal.ext.ip`. A value you send for an SDK signal, as `signals.ip`, is only used when the front-end SDK did not provide one.

Keys are lower snake\_case, at most 64 characters, written **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.

A parameter needs a value. An empty value is refused when you save the recipe - omit the key instead.

The namespaces are separate, so a signal Prelude adds later can never collide with a key of yours.

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 Prelude signals and the target only. It can't reference `signal.ext.` 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":"signal.ext.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}]}'
# → {"id":"rcp_01jd1u7gxxghgtr2ne35nizauk","status":"SHADOW", ...}

# 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"},
       "signals":{"ext":{"plan_tier":"free"}}}'
```

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.