Concepts · Targeting
Types, rules and rollouts
A flag has one fixed type, named variations, and per-environment rules evaluated in a strict order. Percentage rollouts are stable hashes, so nobody flips back and forth, and nothing about your users is stored.
Flag types#
Every flag has exactly one type, chosen at creation and immutable afterwards. Asking for the wrong type (calling boolVariation on a string flag) never coerces; you get your default and an error reason. Changing a type means creating a new flag and archiving the old one.
| Type | Value domain | Accessor (JS / Python) | Notes |
|---|---|---|---|
| boolean | true / false | boolVariation / bool_variation | Always has the two variations on and off. |
| string | UTF-8 string | stringVariation / string_variation | Good for copy, theme names, enum-like switches. |
| int | 64-bit-safe integer | intVariation / int_variation | JSON numbers beyond 253 are rejected on save. |
| float | IEEE-754 double | floatVariation / float_variation | NaN and Infinity are rejected. |
| json | Any JSON value, validated by a schema | jsonVariation / json_variation | Stored JSON Schema (draft 2020-12 subset, no remote $ref). Invalid values are refused at save time, never discovered at runtime. |
| date | RFC 3339 instant, UTC-normalised | dateVariation / date_variation | Returned as a Date in JS and an aware datetime in Python. |
A JSON flag example. The schema lives on the flag and every variation is validated against it:
{
"type": "object",
"required": ["rps", "burst"],
"properties": {
"rps": { "type": "integer", "minimum": 1, "maximum": 1000 },
"burst": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}If a schema edit would invalidate a variation that is in use in any environment, the save is rejected. Fix or replace the variation first.
Variations and environments#
A variation is a named, typed value (on, off, blue, limit-100). Names are unique per flag and are defined at the flag (project) level. Each environment then decides which variation to serve and when:
- enabled
- The environment-level off switch. When false, the flag serves
offVariationand rules are not consulted. - offVariation
- Served when the flag is disabled.
- defaultVariation
- The fallthrough: served when enabled and no target or rule matches. Can be a rollout.
- targetLists
- Ordered lists of user ids, emails, groups or any other attribute, each served one variation. See target lists.
- rules
- Ordered list of rules. Each rule is an OR of AND groups of conditions, with a variation or a percentage rollout.
Evaluation order#
For a flag, an environment and a context, evaluation is a pure function of the snapshot and the context. It runs these steps in this exact order and stops at the first that yields a result:
- Flag missing or ruleset unavailableReturn the caller-supplied default.ERROR / DEFAULT
- Environment disabledServeOFF
offVariation. - Target listsLists are checked in order; the first list whose attribute (TARGET_MATCH
key,email,groups, ...) matches serves its variation. - Rules, top to bottomA rule matches when any of its groups matches; inside a group all conditions must match. The first rule whose conditions all match decides: a fixed variation, or a weighted rollout.RULE_MATCH
- FallthroughServeFALLTHROUGH
defaultVariation(or its rollout).
Worked example#
The production ruleset of new-checkout-flow, with the environment enabled, offVariation: off, salt k3x9:
{
"enabled": true,
"offVariation": "off",
"targetLists": [{ "id": "tl_1", "matchBy": "key", "values": ["usr_lena"], "variation": "on" }],
"rules": [
{ "id": "r1", "if": [{ "attr": "plan", "op": "equals", "value": "enterprise" },
{ "attr": "country", "op": "in", "value": ["BR", "AR"] }],
"serve": { "variation": "on" } },
{ "id": "r2", "if": [{ "attr": "country", "op": "equals", "value": "BR" }],
"serve": { "rollout": [{ "variation": "on", "weight": 2000 },
{ "variation": "off", "weight": 8000 }] } }
],
"defaultVariation": "off"
}| Context | Path | Bucket | Result | Reason |
|---|---|---|---|---|
usr_lena | Matches target list 1 (key) | n/a | on | TARGET_MATCH |
usr_iris · enterprise · BR | Skips targets; both conditions of r1 match | n/a | on | RULE_MATCH |
u_1042 · free · BR | r1 fails (plan); r2 matches, rollout | 120 | on (120 < 2000) | RULE_MATCH (bucket 120) |
u_2291 · free · BR | r1 fails; r2 matches, rollout | 4,562 | off (4562 ≥ 2000) | RULE_MATCH (bucket 4562) |
usr_tomas · pro · US | No target, no rule matches | n/a | off | FALLTHROUGH |
Notice u_2291: a rule that matches always decides, even when the rollout assigns the "off" bucket. It does not fall through to later rules. Put rollouts last, or give each audience its own rule.
Conditions#
A condition tests one attribute of the context. Conditions inside a group are combined with AND, and a rule is an OR of groups.
| Group | Operators | Applies to |
|---|---|---|
| Equality | equals, not_equals, in, not_in | string, number, boolean |
| String | contains, starts_with, ends_with, matches | string; matches is a length-limited, RE2-safe regular expression |
| Numeric | lt, lte, gt, gte | number |
| Date | before, after | RFC 3339 string |
| Semantic version | semver_eq, semver_lt, semver_gt and friends | version string, e.g. app_version |
| Segment | in_segment, not_in_segment | the context key |
If the context lacks the attribute, the positive operators do not match. If the attribute has the wrong type for the operator (a string where gt expects a number), the condition simply does not match. The not_* operators treat a missing attribute as "not matching the positive form", so they are true. For array attributes see array attributes. If you rely on an attribute, send it on every evaluation.
Segments#
A segment is a named, reusable set at the project level, with membership per environment: included lists, excluded lists (both keyed by key, email, groups or another attribute, like target lists) and optional attribute rules. Excluded wins: a context on an excluded list is never a member, whatever the included lists and rules say; then included lists; then attribute rules. Rules reference segments by id, and the snapshot embeds the resolved definitions, so a server SDK needs no extra fetch. Segments cannot reference other segments.
Target users by id, email or group#
The evaluation context stays one flat object. key is the user id; email, name and groups (an array of strings) are the conventional optional fields; anything else is a custom attribute. Target lists choose which attribute they match by:
{
"key": "usr_6140",
"email": "Ana@ACME.com",
"groups": ["beta-testers", "billing-admins"],
"plan": "pro"
}[
{ "id": "tl_1", "matchBy": "key", "values": ["usr_1203", "usr_7712"], "variation": "on" },
{ "id": "tl_2", "matchBy": "email", "values": ["ana@acme.com", "bo@acme.com"], "variation": "on" },
{ "id": "tl_3", "matchBy": "groups", "values": ["beta-testers", "acme-staff"], "variation": "on" },
{ "id": "tl_4", "matchBy": "groups", "values": ["opted-out"], "variation": "off" }
]The lists are checked in order at step 3 and the first list that matches wins. A list matching by groups matches when any element of the context's groups is in values. The console refuses to save a value that serves two different variations within one matchBy (so results are deterministic); across different attributes, list order decides. Lists can be edited in the console (chips, paste box, CSV import) or through the API.
Array attributes#
Attributes may be arrays of strings, numbers or booleans. Positive operators (equals, in, contains, starts_with, ...) are true when any element matches. Negative operators (not_equals, not_in) are true only when no element matches. A missing attribute never matches a positive operator.
| Condition | Result | Why |
|---|---|---|
groups in ["beta", "qa"] | match | "beta" is an element |
groups not_in ["suspended"] | no match | "suspended" is an element |
groups not_in ["banned"] | match | no element is "banned" |
Email normalisation#
The email attribute is normalised on both sides, in lists and in rule operators: Unicode NFC, trim, locale-independent lowercase. Ana@ACME.com in the context matches ana@acme.com in a list, and email ends_with "@acme.com" matches either casing. Every other attribute, including key, is compared exactly. SDKs and the evaluation service share conformance vectors for this.
OR of AND groups#
A rule has one or more groups; a group has one or more conditions. The rule matches when any group matches, and a group matches when all its conditions match. The snapshot field is groups; the older conditions array is read as a single group. Placeholder limits: 5 groups per rule, 10 conditions per group.
{ "id": "r2", "groups": [
[ { "attr": "email", "op": "ends_with", "value": "@example.com" },
{ "attr": "groups", "op": "not_in", "value": ["suspended"] } ],
[ { "attr": "groups", "op": "in", "value": ["acme-staff"] } ]
], "serve": { "variation": "on" } }Targeting lab#
runs the shared engineFlag ai-search-beta, production lists above, default off. Edit the context and see which step decides.
Limits per plan (placeholders)#
Target-list entries per flag and environment, and entries per segment, are bounded per plan so snapshots stay small and fast. Evaluation compiles lists to hash sets when a snapshot loads. These numbers are placeholders, not final pricing.
| Limit | Free | Pro | Business |
|---|---|---|---|
| Target-list entries per flag-environment | 100 | 5,000 | 50,000 |
| Entries per segment | 100 | 5,000 | 50,000 |
Going over a limit blocks the save with the count and the limit. Entries are never truncated silently. CSV import previews added, duplicate, invalid and over-limit counts before anything is applied, and refuses an import that would exceed the limit.
Privacy: lists hold identifiers — prefer opaque ids#
Target and segment lists contain identifiers of your end users, stored in plaintext; we hold them as your processor. They are delivered to server SDK keys only (client keys use remote evaluation and never receive lists). Audit events record counts and masked samples (a***@acme.com), never full values. Prefer opaque user ids over emails where you can, and remove entries when you no longer need them; hashed lists are not part of the MVP.
Worked example#
For the lists above and ai-search-beta (production, default off, no matching rule):
| Context | Result | Reason |
|---|---|---|
key: usr_7712 | on | TARGET_MATCH, list 1 (key) |
email: " Bo@ACME.com " | on | TARGET_MATCH, list 2 (email, normalised to bo@acme.com) |
groups: ["finance", "acme-staff"] | on | TARGET_MATCH, list 3 (groups, any element) |
groups: ["opted-out", "beta-testers"] | on | List 3 matches first (order wins); list 4 is never consulted |
key: usr_1, email: eve@example.com | off | FALLTHROUGH |
Percentage rollouts and bucketing#
A rollout is a list of (variation, weight) pairs with weights in basis points summing to exactly 10,000 (so 0.01% granularity; 10% is 1,000). Choosing a variation for a context is deterministic and stateless:
- Pick the bucketing attribute.
context.keyby default, or a configured attribute such asorgIdto keep a whole account on the same side. If the attribute is missing, the rollout step is skipped and evaluation continues to the fallthrough. - Build the input string
"{flagKey}.{salt}.{attributeValue}". The salt is a random value created with the flag, per environment. - Hash it: MurmurHash3, x86, 32-bit, seed 0, over the UTF-8 bytes.
- Bucket =
hash mod 10000, an integer from 0 to 9999. - Walk the variations in listed order, accumulating weights; serve the first whose cumulative weight is greater than the bucket.
Bucketing calculator#
runs the real algorithm| User key | Bucket | At 10% | At selected % |
|---|
Properties you can rely on#
- Sticky. Same flag, salt and key always produce the same bucket. No state is stored; there is nothing to sync between SDKs.
- Monotonic. Raising a variation's weight from 10% to 20% (1,000 to 2,000 bps) only moves users into it: buckets 0 to 999 stay, 1,000 to 1,999 join. Nobody who had the new experience loses it. This holds as long as the variation order is unchanged.
- Independent across flags. The flag key is part of the hash input, so the 10% on one flag are not the same 10% on another.
- Identical everywhere. Every SDK and the
evalservice run the same algorithm against shared conformance vectors.
Regenerating the salt, reordering the variations of a rollout, or switching the bucketing attribute moves users between buckets. Do it deliberately (for instance to restart an experiment), never as part of a routine edit. The console warns before saving any of these.
What this means for privacy#
Because bucketing is a function of the key and a salt, we never record "user X saw variation Y". Debug a specific user in the evaluation playground, where you type a context for one call; it is not stored. See telemetry for the aggregate counters we do keep.
Thanks. This helps us prioritise the docs.