Predicates
A predicate is a recursive boolean expression that defines audience membership. Predicates appear in two places:
[segment.predicate]inside segment files — the original home, reusable across flags.predicate = { ... }inside a flag rule — inline, convenient for one-off audiences. See flag § Inline predicates.
The shape, operators, and validation rules below apply to both sites identically. A flag rule MAY use segment = "..." instead of an inline predicate; a single rule MUST declare exactly one of segment or predicate (mixing both raises E036).
Operator quick reference
The full operator set, one line per operator. See § Per-operator notes for behavior details.
| Operator | Operand | Operand type | Attribute type | One-line semantics |
|---|---|---|---|---|
eq | value | scalar | string / int / float / bool | Attribute equals operand. |
neq | value | scalar | string / int / float / bool | Attribute does not equal operand. |
gt | value | number | int / float | Attribute strictly greater than operand. |
gte | value | number | int / float | Attribute ≥ operand. |
lt | value | number | int / float | Attribute strictly less than operand. |
lte | value | number | int / float | Attribute ≤ operand. |
in | values | non-empty scalar array | string (or matching scalar) | Attribute equals one of the values. |
not_in | values | non-empty scalar array | same as in | Attribute equals none of the values. |
contains | value | string | string | Substring match. Case-sensitive. |
not_contains | value | string | string | Substring non-match. Case-sensitive. |
starts_with | value | string | string | Prefix match. Case-sensitive. |
ends_with | value | string | string | Suffix match. Case-sensitive. |
semver_eq | value | string (semver) | string (semver) | Attribute semver == operand semver. |
semver_gt | value | string (semver) | string (semver) | Attribute semver > operand semver. |
semver_gte | value | string (semver) | string (semver) | Attribute semver ≥ operand semver. |
semver_lt | value | string (semver) | string (semver) | Attribute semver < operand semver. |
semver_lte | value | string (semver) | string (semver) | Attribute semver ≤ operand semver. |
is_set | — | — | any | Attribute key present in the evaluation context. |
is_not_set | — | — | any | Attribute key absent. |
The set is closed. Operator names are byte-exact case-sensitive (op = "EQ" is E015). The set-typed operators (set_contains / set_not_contains), the string_set attribute type, and matches_regex are reserved for a future minor version.
Predicate forms
Three top-level forms are valid:
| Form | TOML shape | Example |
|---|---|---|
| Attribute atom | inline table with attribute, op, and value (or values) | { attribute = "user.country", op = "eq", value = "US" } |
| Segment reference | inline table with segment | { segment = "beta-users" } |
| Compound expression | inline table whose key is and, or, or not; value is an array (for and / or) or a sub-predicate (for not) | { and = [ … ] } |
Compound expressions can nest other compound expressions, atoms, and segment references to arbitrary depth. The linter emits W005 when nesting exceeds 5 levels.
Attribute atoms
An attribute atom tests a single attribute from the evaluation context against an operand.
{ attribute = "<dot.path>", op = "<operator>", value = <operand> }
or, for operators that take a list:
{ attribute = "<dot.path>", op = "<operator>", values = [<operand>, <operand>, ...] }
| Field | Type | Required | Description |
|---|---|---|---|
attribute | string | yes (absent → E015) | Dot-separated path into the evaluation context's attributes. The leading prefix (user, app, …) is conventional but not enforced. |
op | string | yes | Operator name. |
value | scalar (string / int / float / bool) | depends on operator | Single operand for scalar / comparison / string / semver operators. |
values | array of scalars | depends on operator | Operand list for in / not_in. |
The linter validates atom shape via E015:
opMUST be a string and a known operator name.- The operand field (
valuevs.values) MUST be the one the operator expects. - The operand's TOML type MUST match the operator's signature.
- Operators that take no operand (
is_set,is_not_set) MUST NOT carry avalueorvaluesfield.
Unknown sibling keys on an atom table (anything other than attribute / op / value / values) produce E016. Mixing atom keys with and / or / not / segment at the same level remains E015 (ambiguous shape).
Attribute paths
Attribute paths are literal keys into the flat evaluation-context attribute map. The dot is a convention for readability; the SDK does NOT split on it or look up nested objects. An atom whose attribute is "user.country" matches the context entry whose key is exactly the string "user.country".
See evaluation-context for the conventional prefixes (user.*, app.*, org.*, …) and missing-attribute behavior.
Per-operator notes
The quick reference above covers the basic shape. Operators with non-obvious behavior:
in / not_in — operand requirements
The values array MUST contain at least one entry; values = [] produces E033. An empty in would always evaluate to false and an empty not_in to true; both forms are degenerate and almost always authoring oversights.
The array's entries MUST all be scalars (string, integer, float, or boolean); a non-scalar entry (table or array) produces E015. Arrays MAY mix scalar types (e.g., [1, "1"]) — the runtime comparison is per-entry against the attribute value following the same type rules as eq — but uniform-type arrays are strongly recommended for readability.
Substring operators with empty-string operand
contains, not_contains, starts_with, and ends_with with value = "" are mathematically degenerate — every string contains, starts with, and ends with the empty string. An author writing such an atom is almost always making a mistake (forgot to fill in the operand, or copied a placeholder). The linter raises W015. To express a true-everywhere atom intentionally, use op = "is_set" against a known-present attribute.
Case sensitivity and Unicode
All string operators are case-sensitive and byte-exact. Comparison runs on the raw UTF-8 byte sequences: no Unicode normalization (NFC, NFD, NFKC, NFKD), no case folding, no locale-aware collation, no whitespace trimming.
Two strings that look identical to a human but use different Unicode encodings (e.g., precomposed é U+00E9 vs. decomposed e + U+0301) are not equal under eq. SDKs and the server MUST compare bytes verbatim.
To do a case-insensitive or normalized comparison, normalize the attribute on the SDK side before evaluation, or define a derived attribute (user.country_lower).
Numeric widening on eq / neq / comparison
The numeric operators (gt, gte, lt, lte) and eq / neq accept either integer or float on either side. When attribute and operand differ in numeric type, both sides are widened to IEEE 754 double for the comparison. eq between 1 (integer attribute) and 1.0 (float operand) is true.
Pin the same numeric type on both sides when authoring rules. Widening exists to make obvious cross-type comparisons work, not as license to mix freely.
Type mismatch at evaluation time
If the runtime attribute type does not match what the operator requires (e.g., gt against a string attribute), the predicate evaluates to false. This is not an error; the segment simply does not match. SDKs MAY log a warning when an explicit context schema is configured; with no schema, the mismatch is silent.
The linter does its own static check across files: an attribute used under operator/operand pairs that imply different types raises E034. See evaluation-context § Inferred attribute types.
Missing-attribute behavior
If the predicate's attribute is absent from the evaluation context:
is_setreturnsfalse;is_not_setreturnstrue.- All other operators return
false.
Missing attributes never cause evaluation to error.
Semver parsing
Semver operators expect both attribute value and operand to be valid semver 2.0 strings. The linter validates the operand string at lint time and emits E015 if it is not parseable. The attribute side is dynamic and validated at evaluation time: an attribute that fails to parse as semver causes the atom to evaluate to false.
Segment references
A predicate atom of the form { segment = "<key>" } tests whether the entity is a member of the named segment. Segment references can appear at any position in a predicate tree.
[segment.predicate]
or = [
{ attribute = "user.country", op = "eq", value = "US" },
{ segment = "beta-users" },
]
The referenced segment is evaluated recursively against the same evaluation context. If the referenced segment defines a bucket, the bucket's entity_id_attribute is read from the same context.
Validation rules
- The referenced key MUST exist as a segment file in the flag namespace; otherwise
E005. - Segment references MUST NOT participate in cycles; otherwise
E012. - Segment references appearing inside
and/or/notare still validated. - A segment reference is not an attribute atom and therefore not subject to
E015operator validation.
Negation
{ not = { segment = "beta-users" } }
Negates segment membership. Equivalent to "the entity is not in beta-users".
Compound expressions
Three boolean combinators compose predicates into trees: and, or, not.
and
{ and = [
{ attribute = "user.country", op = "eq", value = "US" },
{ attribute = "user.plan", op = "in", values = ["pro", "enterprise"] }
]}
- TOML shape: a table with key
andwhose value is an array of sub-predicates. A non-array value raisesE015. - Semantics:
trueiff every sub-predicate istrue. - Short-circuit: stops at the first
falsein declared order. - Each array entry MUST be a TOML table. A non-table entry such as
and = [42, "x"]raisesE015.
An empty and = [] is vacuous truth (true). The linter emits W007 so the empty list is an explicit choice rather than an oversight.
or
{ or = [
{ attribute = "user.plan", op = "eq", value = "pro" },
{ segment = "beta-users" }
]}
- TOML shape: a table with key
orwhose value is an array of sub-predicates. - Semantics:
trueiff any sub-predicate istrue. - Short-circuit: stops at the first
truein declared order.
An empty or = [] is false. The linter emits W007.
not
{ not = { attribute = "user.plan", op = "eq", value = "free" } }
- TOML shape: a table with key
notwhose value is a single sub-predicate (not an array). An array or scalar value raisesE015. - Semantics: boolean negation of the wrapped predicate.
A not whose body is itself not { ... } is permitted but discouraged; double negation reads poorly.
Unknown sibling keys on a compound table
A compound table MUST contain exactly one of and, or, not. Any sibling key on the same table — { and = [...], extra = "x" }, or two compound keys at once like { and = [...], or = [...] } — produces E016. The same applies to segment-reference tables: { segment = "x", extra = "y" } raises E016.
Mixing and nesting
Combinators may be mixed and nested:
{ and = [
{ attribute = "user.country", op = "eq", value = "US" },
{ or = [
{ attribute = "user.plan", op = "eq", value = "pro" },
{ segment = "beta-users" }
]}
]}
The linter measures nesting depth as the longest chain of and / or / not keys from the root. A predicate exactly 5 levels deep does NOT trigger W005; 6 levels deep does. Atom tables and segment-reference tables do not count as a level — only and / or / not keys increment the depth.
Top-level predicate shapes
The [segment.predicate] table can be written as:
Atom directly under the table
[segment.predicate]
attribute = "user.country"
op = "eq"
value = "US"
A single attribute atom inlined at the predicate root.
Compound at the root
[segment.predicate]
and = [
{ attribute = "user.country", op = "eq", value = "US" },
{ attribute = "user.plan", op = "in", values = ["pro", "enterprise"] }
]
Segment reference at the root
[segment.predicate]
segment = "beta-users"
A [segment.predicate] MUST declare exactly one of these three shapes. Mixing atom keys (attribute / op) with compound keys (and / or / not) or with segment at the same table level produces an ambiguous shape and is rejected with E015.
An empty [segment.predicate] table (no keys at all — neither atom keys, nor compound keys, nor segment) is rejected with E015. A segment that needs to match every entity should use a tautology atom (e.g., attribute = "user.id", op = "is_set"); a segment that needs to match no entity should use a negation of such a tautology.
Worked examples
"US users on the pro or enterprise plan"
[segment.predicate]
and = [
{ attribute = "user.country", op = "eq", value = "US" },
{ attribute = "user.plan", op = "in", values = ["pro", "enterprise"] }
]
"Beta cohort minus suspended users"
[segment.predicate]
and = [
{ segment = "beta-users" },
{ not = { attribute = "user.status", op = "eq", value = "suspended" } }
]
"Internal employees, OR users on a recent app version"
[segment.predicate]
or = [
{ segment = "internal-employees" },
{ attribute = "app.version", op = "semver_gte", value = "2.4.0" }
]
"Has the email attribute set, and the email contains the company domain"
[segment.predicate]
and = [
{ attribute = "user.email", op = "is_set" },
{ attribute = "user.email", op = "contains", value = "@example.com" }
]
Common diagnostics
| Scenario | Diagnostic |
|---|---|
op is not a string | E015 |
op is an unrecognized operator (including wrong-case forms) | E015 |
Operator requires value but the atom has values (or vice versa) | E015 |
Operator requires no operand but the atom has value or values | E015 |
| Operand type does not match operator's signature | E015 |
values array contains non-scalar entries | E015 |
values = [] on in / not_in | E033 |
Atom omits attribute | E015 |
| Atom mixes atom keys with compound keys at the same level | E015 |
| Atom contains an unknown sibling key | E016 |
[segment.predicate] table is empty | E015 |
Compound's and / or value is not an array | E015 |
Compound's not value is an array | E015 |
Compound's and / or array contains a non-table entry | E015 |
Compound table contains a sibling key alongside and / or / not | E016 |
Segment-reference table contains a sibling key alongside segment | E016 |
| Semver operand fails to parse | E015 |
| Predicate references a missing segment | E005 |
| Predicates form a cycle | E012 |
| Predicate nesting exceeds 5 levels | W005 |
Empty and = [] or or = [] | W007 |
Substring op with value = "" | W015 |
| Same attribute used under operator/operand pairs implying different types | E034 |
Recommended practice
- Read predicates top-to-bottom, left-to-right. Order the children of
and/orso the most discriminating sub-predicate appears first; short-circuit returns earlier. - Prefer segment composition over deeply nested predicates. A segment whose predicate is
or = [ { segment = "beta-users" }, { segment = "internal-employees" } ]is easier to read and maintain than a 6-deep predicate tree. - Always namespace attributes (
user.id,app.version,device.os). Reviewers reading a predicate should be able to tell at a glance what the attribute represents. - Avoid mixing semver and string
eq. A version comparison viaeqwill mis-order"2.10.0"versus"2.2.0"— use the semver operators when comparing version numbers. - Pin a segment for every audience your team uses more than once. Inline-only audiences create copy-paste drift.
See also
- segment — where
[segment.predicate]lives. - flag — inline
predicate = { ... }on rules. - evaluation-context — attribute lookup, missing-attribute and type-mismatch semantics.
- buckets —
[segment.bucket], the other half of segment membership. - diagnostics —
E005,E012,E015,E016,E033,E034,W005,W007,W015.