Authoring is in beta. The request and response shapes may still change, and the Management API is enabled per account. Contact us to turn it on.
The three objects
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, then recipe, then 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 itsthreshold. A rule that did not trigger or that could not run do not affect the score.
Rules that could not run
A rule reading a signal that never arrived reportsNOT_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 fills in most of what rules read.
Overriding rules
Setoverrides 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.
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.
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’sexpression is CEL 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:
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.
targets is a list of {type, value} entries, with type phone_number or email_address. Read an entry as targets[0].value, count them with targets.size(), or use the functions that read them:
When a request has exactly one phone number, a rule can read it as
target.phone_number. When it has exactly one email address, a rule can read it as target.email_address. With none or more than one, the value is absent and a rule reading it reports NOT_EVALUATED. Use them when your requests always send one value of a type, and targets when they can send several.
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, 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. Asignal. 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.
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 compare your own value with something Prelude also resolves, such as the IP address your backend saw against signal.ip, send it as one of your own signals (for example signals.ext.backend_ip) and read it as signal.ext.backend_ip.
Keys start with a letter, contain only letters, digits and underscores (A-Z, a-z, 0-9, _), and are at most 64 characters. Keys are written without the namespace, and there are 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 - Prelude’s own, shared by every customer - alongside yours. Name one inrules 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 targets 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 answers409 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
status: "LIVE". The flow identifier never changed, so nothing in your application has to.