Feature Flags
Targeting Rules
CEL rules decide which variant an instance receives — what a rule can read under __kaiten, how rules are ordered and evaluated, percentage and date rollouts, and the three operations that check a rule before it is saved.
How targeting works
A targeting rule decides which variant an instance receives when a flag is evaluated. Rules are written in CEL (Common Expression Language), a small, side-effect-free expression language: a rule is an expression that reads the evaluation context and yields true or false.
Rules are evaluated in order, top to bottom. The first rule that matches serves its variant. When no rule matches, the flag serves its default variant.
Examples on this page use $KAITEN_API and $KAITEN_TOKEN as set up in the quickstart, and the seeded Kaiten Sushi Shop flags. Responses are illustrative — ids and timestamps depend on your stack.
The shape of a rule
Rules live in the flag's targetings array. Every rule has a type, a name, and a CEL rule; what it serves depends on the type:
type | Serves | Fields |
|---|---|---|
basic | One fixed variant | variant — the name of the variant to serve |
rollout_percentage | A weighted split across variants | distribution — { "<variant>": <weight> } |
rollout_date | A share that grows between two dates | start and end, each { "variant", "percentage", "date" } |
The seeded promo-banner flag carries one basic rule:
{
"targetings": [
{
"type": "basic",
"name": "Standard or Premium license",
"rule": "__kaiten.license.familySlug in ['standard', 'premium']",
"variant": "salmon-week"
}
]
}targetings is written with the rest of the flag on POST /feature-flags and PUT /feature-flags/{featureFlagSlug} — PUT replaces the whole flag, so resend every rule to keep. Create and manage flags covers the full request.
What a rule can read
A rule reads two things: the context your product sends with the evaluation, and the facts Kaiten adds to it.
Your context
The evaluation request carries a context object with two reserved keys. targetingKey is required — without it the evaluation fails with the error code PROVIDER_FATAL and serves no value — and when it names a customer slug, Kaiten resolves the License and entitlement facts of that customer's oldest instance, the one created first. kaiten.instanceSlug (or kaiten.instanceId) names the instance being evaluated and resolves the instance, customer, and deployment-zone facts below. Every other key is yours: a rule can compare any attribute you send.
The two keys resolve independently. For a customer with one instance they describe the same instance; for a customer with several, the License and entitlement facts always come from the oldest one, whichever instance kaiten.instanceSlug names. Ninja Osaka's two instances show it: ninja-osaka-prod on Starter, created first, and ninja-osaka-delivery on Standard v2 — a rule reading __kaiten.license or __kaiten.entitlements with the targeting key ninja-osaka reads Starter, even next to "instanceSlug": "ninja-osaka-delivery".
{ "context": { "targetingKey": "sakura-tokyo", "kaiten": { "instanceSlug": "sakura-tokyo-prod" }, "orderCount": 12 } }The seeded analytics-dashboard-enabled flag combines both halves — a server fact and a caller attribute:
__kaiten.customer.slug == 'sakura-tokyo' && orderCount > 10Kaiten enforces no schema on the keys you send. A rule that reads a key the context does not carry does not evaluate to false — it fails, and a failed rule is skipped (see broken rules). Use has() when a field of an attribute may be absent (has(user.cohort)); it cannot test a top-level key, which the context has to carry.
The __kaiten facts
Before a rule runs, Kaiten places server-computed facts under the reserved root __kaiten — the only identifier a rule author needs to know. Everything the server computes lives two levels down (__kaiten.license.familySlug), so your own attribute names never collide with it, and nothing a caller sends under __kaiten survives: the namespace is reset from the server's data on every evaluation.
| Fact | Fields | Notes |
|---|---|---|
__kaiten.license | slug, familySlug, type | The License version the customer's oldest instance is pinned to. slug names that version (premium-v2); familySlug names the product, the same for every version (premium). type is DEVELOPMENT, TRIAL, PAID, or COMMUNITY |
__kaiten.entitlements['<slug>'] | Depends on the entitlement's type | One entry per entitlement the License grants, keyed by entitlement slug, in the shape of its type: a NUMBER entitlement reads as an object with limit, used, remaining, percentage and unlimited, always all five; a BOOLEAN one as the boolean the License grants; a CONFIG one as the object the License grants |
__kaiten.instance | id, slug, name, status, lifecycleStage, metadata | lifecycleStage may be absent; metadata holds the instance's typed metadata fields |
__kaiten.customer | id, name, slug, externalCustomerId, domain | externalCustomerId and domain may be absent |
__kaiten.deploymentZone | id, name, slug, type, metadata, currentReleaseId | The zone the instance runs in; currentReleaseId is absent for a zone that has deployed nothing yet |
The list is derived from the same declarations the server writes the facts from, so what the linter accepts and what an evaluation reads cannot disagree. GET /feature-flags/targeting/context returns it for your organization, entitlement slugs included — see discover what a rule can read.
An instance carries metadata and no other free-form object — a rule written on a field the facts no longer have is refused by the lint rather than left to never match.
Unlimited grants and absent values
A NUMBER entitlement's entry is never partial: a grant reads with all five fields, whatever its value. This matters because CEL does not treat a missing field as false — it raises an error, and a rule in error is skipped. An unlimited grant (-1 on the License) therefore reads as unlimited: true with a large limit and remaining, so that every comparison an author can write comes out as it reads:
// Ninja Osaka's Starter grants 1 location; Sakura Tokyo's Premium grants unlimited locations
__kaiten.entitlements['locations'].remaining < 2 // true for Ninja Osaka, false for Sakura Tokyo
__kaiten.entitlements['locations'].limit > 1000 // false for Ninja Osaka, true for Sakura Tokyo
__kaiten.entitlements['locations'].unlimited // says it plainlyAn entitlement the License does not grant at all has no entry. == true does not match it, and neither does a negation such as !__kaiten.entitlements['sso'], which is an error on a missing key; test the key with in first when a License may leave an entitlement out. Every Kaiten Sushi Shop License grants all six entitlements — Starter grants delivery-tracking: false explicitly — so the seeded rules never meet the case.
Fields that may be absent — lifecycleStage, externalCustomerId, domain, currentReleaseId — are the ones to guard with has():
has(__kaiten.instance.lifecycleStage) && __kaiten.instance.lifecycleStage == 'AT_RISK'Writing rules
Every example below is a valid rule for a Kaiten Sushi Shop flag.
Read the contract
// A BOOLEAN grant reads as the boolean itself: the delivery-tracking-enabled rule,
// "Delivery tracking entitlement granted" — on for Premium and Standard v2, off for Starter and Standard v1
__kaiten.entitlements['delivery-tracking'] == true
// A CONFIG grant reads as the object the License grants: Premium's priority support
__kaiten.entitlements['support-tier'].tier == 'priority'
// A product, whatever its version: every version of Premium
__kaiten.license.familySlug == 'premium'
// One version of it, by its slug
__kaiten.license.slug == 'premium-v2'
// A License by type
__kaiten.license.type == 'PAID'
// Headroom on a quota
__kaiten.entitlements['monthly-orders'].remaining < 50Each entitlement is read in the shape of its type, and the lint refuses a rule that reads one out of it — a field on a BOOLEAN entitlement (.limit), a BOOLEAN or CONFIG used as a number, a NUMBER compared as a value — with a message giving the form to write.
Target a product by familySlug. A rule on slug names one version and stops matching the customers moved to the next one: __kaiten.license.slug == 'premium' matches Kappa Kyoto alone, still on Premium's archived first version, and not Sakura Tokyo, on premium-v2.
Read the instance, the customer, the zone
// One instance
__kaiten.instance.slug == 'sakura-tokyo-prod'
// Operational and commercial state
__kaiten.instance.status == 'MAINTENANCE'
has(__kaiten.instance.lifecycleStage) && __kaiten.instance.lifecycleStage == 'TRIAL'
// Typed metadata, on the instance and on the zone
__kaiten.instance.metadata.environment == 'demo'
__kaiten.deploymentZone.metadata.dedicated == true && __kaiten.deploymentZone.metadata.compliance_profile == 'pci-dss'
// The zone's environment class
__kaiten.deploymentZone.type == 'production'
// The customer
__kaiten.customer.slug in ['sakura-tokyo', 'ninja-osaka']
has(__kaiten.customer.externalCustomerId) && __kaiten.customer.externalCustomerId.startsWith('crm-')Combine
// AND, OR, NOT
__kaiten.license.familySlug == 'premium' && __kaiten.instance.status != 'INCIDENT'
__kaiten.license.familySlug == 'standard' || __kaiten.license.familySlug == 'premium'
!(__kaiten.instance.metadata.environment == 'demo')
// String helpers
__kaiten.instance.slug.startsWith('sakura-')
__kaiten.customer.name.contains('Osaka')
// Everyone — the condition a rollout rule usually carries
trueEvaluation order
1. enabled?
└── no → default variant, reason DISABLED — no rule is evaluated
└── yes → continue
2. targeting rules, in order
├── rule fails to run → skipped, recorded (see below)
├── rule is false → next rule
└── rule is true → serve its variant (TARGETING_MATCH, or SPLIT for a rollout)
3. no rule matched → default variant, reason DEFAULTThe first matching rule wins, so specific rules go before broad ones. The seeded new-ordering-flow flag is the pattern: rule 1 serves on to Demo Restaurant alone; a rule 2 with true and a 10 % distribution would then apply to every instance rule 1 did not catch — Demo Restaurant never reaches it. Read in the other order, Demo Restaurant would be bucketed like everyone else. The progressive rollouts guide walks through that sequence.
enabled: false is the hard kill switch: no rule runs, even one that would match everyone.
Broken rules
A typo in one expression — an entitlement slug your organization does not have, an attribute the context did not carry — does not silently turn a feature off for everyone: evaluation moves on to the next rule and then to the default, and the failure is attached to the result — in the server log with the flag and rule name, and in the evaluation response's flag metadata under kaiten.brokenTargetingRules, where the SDK or the console surfaces it to whoever wrote the rule.
The three operations below exist so that a broken rule is caught before it is saved.
Checking a rule before saving it
Creating or updating a flag runs a check on every rule and refuses a rule that cannot be read. The same check, and the evaluation engine itself, are exposed as three operations so an editor can say what is wrong while the rule is being written. They write nothing, meter nothing, and audit nothing. The console's rule editor is built on them.
Discover what a rule can read
GET /feature-flags/targeting/context — requires read:feature_flags.
Returns the identifiers a rule may read, with their types and descriptions, including your organization's own entitlement slugs. It is what completion and hover documentation are built from.
curl "$KAITEN_API/feature-flags/targeting/context" \
-H "Authorization: Bearer $KAITEN_TOKEN"The response is a tree of nodes under roots. Each node has a name as it is written in a rule, a type (string, number, boolean, object, map, or dyn), a description, and, depending on the type, fields (the children of an object), values (the shape every entry of a map shares), entries (the shape of each known key, for a map whose entries differ — __kaiten.entitlements) and knownKeys (the keys a map currently has). optional: true marks a value that may be absent — where has() is worth using. Abridged, for Kaiten Sushi Shop:
{
"roots": [
{
"name": "__kaiten",
"type": "object",
"description": "…",
"fields": [
{ "name": "license", "type": "object", "fields": [
{ "name": "slug", "type": "string", "description": "…" },
{ "name": "familySlug", "type": "string", "description": "…" },
{ "name": "type", "type": "string", "description": "…" }
]},
{ "name": "entitlements", "type": "map",
"knownKeys": ["delivery-drivers", "delivery-tracking", "locations", "menu-items", "monthly-orders", "support-tier"],
"entries": [
{ "name": "delivery-drivers", "type": "object", "fields": [
{ "name": "limit", "type": "number" }, { "name": "used", "type": "number" },
{ "name": "remaining", "type": "number" }, { "name": "percentage", "type": "number" },
{ "name": "unlimited", "type": "boolean" }
]},
{ "name": "delivery-tracking", "type": "boolean", "description": "…" },
{ "name": "support-tier", "type": "map", "values": { "name": "", "type": "dyn" } },
"…"
]
},
{ "name": "instance", "type": "object", "fields": [ "…" ] },
{ "name": "customer", "type": "object", "fields": [ "…" ] },
{ "name": "deploymentZone", "type": "object", "fields": [ "…" ] }
]
},
{ "name": "targetingKey", "type": "string", "description": "…" }
]
}The set is not closed: a rule may also read attributes your product sends in its own context, which the server cannot know in advance.
Lint a rule
POST /feature-flags/targeting/lint — requires write:feature_flags.
Runs the check that POST and PUT run before accepting a rule, and reports what it found without writing anything. A blank rule is itself a mistake it reports.
curl -X POST "$KAITEN_API/feature-flags/targeting/lint" \
-H "Authorization: Bearer $KAITEN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "rule": "__kaiten.entitlements[\"seats\"].remaining < 5" }'{
"valid": false,
"issues": [
{ "message": "…", "line": 1, "column": 23, "endLine": 1, "endColumn": 30 }
]
}Kaiten Sushi Shop has no seats entitlement, so the write path would refuse this rule; the lint says so first. valid is whether the write path would accept the rule; issues lists every problem, positioned where it is written (1-based line and column, 0 when a problem has no position), each message in the same words the write path refuses it with. A rejected rule is a 200 carrying valid: false, not a client error: the request itself was well-formed.
Rehearse a rule against a context
POST /feature-flags/targeting/test — requires write:feature_flags.
Runs a rule the way an evaluation would, without a flag: the context is enriched from the same sources — License, entitlements, instance, customer, deployment zone — anything the caller placed under __kaiten is discarded exactly as at a real evaluation, and the same engine answers. The response carries the enriched facts, so an author can see exactly what the rule read — which is usually the answer to why it did or did not match.
Rehearse the delivery-tracking-enabled rule for Ninja Osaka, whose Starter License grants delivery-tracking: false:
curl -X POST "$KAITEN_API/feature-flags/targeting/test" \
-H "Authorization: Bearer $KAITEN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rule": "__kaiten.entitlements[\"delivery-tracking\"] == true",
"targetingKey": "ninja-osaka",
"context": { "kaiten": { "instanceSlug": "ninja-osaka-prod" } }
}'{
"valid": true,
"issues": [],
"matched": false,
"facts": {
"license": { "slug": "starter", "familySlug": "starter", "type": "COMMUNITY" },
"entitlements": {
"delivery-tracking": false,
"…": "…"
},
"instance": { "id": "…", "slug": "ninja-osaka-prod", "name": "Ninja Osaka Production", "status": "HEALTHY", "metadata": { "environment": "production" } },
"customer": { "id": "…", "slug": "ninja-osaka", "name": "Ninja Osaka", "externalCustomerId": "crm-2048" },
"deploymentZone": { "id": "…", "slug": "shared-apac", "name": "Shared APAC", "type": "production", "metadata": { "region": "ap-northeast-1", "compliance_profile": "standard", "dedicated": false }, "currentReleaseId": "…" }
}
}The rule is valid and did not match, and facts shows why: delivery-tracking is false under Starter. The same request with "targetingKey": "sakura-tokyo" and "instanceSlug": "sakura-tokyo-prod" matches — Premium grants the entitlement. With "instanceSlug": "ninja-osaka-delivery" instead, instance changes and license does not: the targeting key ninja-osaka still resolves to the customer's oldest instance, on Starter. The fields:
| Field | Meaning |
|---|---|
valid, issues | The lint result. The rule is only run when there are no issues |
matched | Whether the rule matched this context. Meaningful only when valid is true and evaluationError is empty |
facts | The __kaiten namespace as enriched for this context — what a rule reads. An empty sub-root means the context resolved nothing for it |
evaluationError | Why the engine could not answer, when it could not — for example, the rule reads a host attribute this context does not carry. At a real evaluation the rule would not match |
The request also accepts targetingKey, the key the evaluation would be bucketed on; when it names a customer slug, the License and entitlement facts are resolved from it.
Percentage and date rollouts
Two rule types serve a share of the population instead of a fixed variant. Both carry a rule like any other — true applies them to every subject that reached them — and both report reason SPLIT.
rollout_percentage splits by weight. The seeded random-discount flag is a 50/50 experiment:
{
"type": "rollout_percentage",
"name": "50/50 rollout",
"rule": "true",
"distribution": { "on": 50, "off": 50 }
}Only positive weights take part: a variant weighted 0 is never served. Variants are ordered by name when the buckets are laid out, so the mapping from bucket to variant is deterministic.
rollout_date grows a share on a schedule: the percentage interpolates linearly between start and end.
{
"type": "rollout_date",
"name": "Ramp to everyone in two weeks",
"rule": "true",
"start": { "variant": "off", "percentage": 10, "date": "2026-09-14T00:00:00Z" },
"end": { "variant": "on", "percentage": 100, "date": "2026-09-28T00:00:00Z" }
}The share receives the end variant and everyone else the start variant. Before 14 September every subject receives off; on 14 September 10 % receive on; on 21 September about 55 %; from 28 September all of them. Once its rule matches, a date rollout always serves one of its two variants: evaluation does not continue to the next rule or the default.
How subjects are bucketed
The subject of a rollout is the evaluation's targeting key. Kaiten hashes the flag's key and the targeting key together and reduces the digest to one of 10 000 buckets; the distribution's weights are laid over those buckets. Two properties follow. Assignment is stable: nothing random enters the hash, so the same subject receives the same variant on every evaluation of that flag, across requests, restarts, and replicas. Rollouts are independent per flag: the flag key salts the hash, so the subjects in the first 10 % of one rollout are not the first 10 % of the next. The two inputs are separated in the hash, so no flag key can be chosen to reproduce another flag's split.
CEL reference
CEL is a safe, non-Turing-complete language. The operators and functions rules most often need:
| Category | Operators / functions |
|---|---|
| Comparison | ==, !=, <, >, <=, >= |
| Logical | &&, ||, ! |
| Membership | in — x in ['a', 'b'] |
| Strings | .startsWith(), .endsWith(), .contains(), .matches(), size() |
| Lists | size(), .exists(), .all() |
| Presence | has() — has(__kaiten.instance.lifecycleStage) |
Strings take single or double quotes. Rules are limited to 10 000 characters.
CEL expressions are evaluated in a sandbox. They cannot make network calls, access the filesystem, or perform any side effect, which is what makes them safe to accept from the console and from your own tooling alike.
Where to go next
What a rule serves: Variants. Writing the flag that carries the rules: Create and manage flags. Evaluating over OFREP and from an OpenFeature SDK: OpenFeature. The one-screen version of the model: Feature Flags. A staged rollout end to end: Progressive rollouts.