ShipEasy
Flags & ExperimentsConfigs

Targeting & rollouts

Express "show this to the right users" with attribute rules, deterministic percentage rollouts, and per-gate salts. The rules engine, in detail.

Production readyOn this page · 10 min readUpdated · May 3, 2026Works with · Server SDK · Browser SDK

A targeting rule on a gate is a predicate of the form attr op value. Multiple rules on a gate are ANDed together. To OR, use the in operator with an array, or split into separate gates and combine in code.

This page is the operational reference for the rules engine: every operator, the bucketing math, and the patterns we've seen work for safe rollouts.

Operators

OperatorWorks onExample
eq / neqanyplan eq "pro"
in / not_inscalar vs arraycountry in ["US","CA","MX"]
gt / gte / lt / ltenumberstenure_days gt 30
containsstring⊂string, array∋valueroles contains "admin"
starts_with / ends_withstrings onlyemail ends_with "@acme.com"
regexstrings onlyemail regex "@acme\\.com$"
version_gte / version_ltsemver stringsapp_version version_gte "2.4.0"

Numeric ops coerce strings that look like numbers — "100" gt 50 is true. They never coerce booleans. regex is anchored only when you anchor it (^ / $); we run it in the SDK, no server round-trip.

Common rule shapes

// Beta cohort — explicit list
{ attr: "user_id", op: "in", value: ["u_1", "u_2", "u_3"] }

// Internal users only
{ attr: "email", op: "regex", value: "@acme\\.com$" }

// Pro plan in supported regions
[
  { attr: "plan",    op: "eq", value: "pro" },
  { attr: "country", op: "in", value: ["US","CA","UK"] },
]

// Long-tenured power users
[
  { attr: "tenure_days", op: "gte", value: 60 },
  { attr: "team_size",   op: "gte", value: 5 },
]

// Mobile app, version-gated
[
  { attr: "platform",    op: "eq",          value: "ios" },
  { attr: "app_version", op: "version_gte", value: "2.4.0" },
]

The dashboard shape is JSON, so you can copy these straight in — or push them with the CLI:

shipeasy flags rules set new-checkout-flow --json '[
  { "attr": "plan",    "op": "eq", "value": "pro" },
  { "attr": "country", "op": "in", "value": ["US","CA","UK"] }
]'

Rollout percentages

Rollout is deterministic. The SDK computes:

bucket = murmur3(salt + ":" + (user_id ?? anonymous_id)) % 10000
true   = bucket < rolloutPct

That gives you three properties:

  • Stable: the same user always gets the same bucket for the same gate. No flicker on reloads, no flicker across servers.
  • Independent: changing one gate's rollout doesn't move users on another gate (different salts).
  • Fair: bucketing is uniform; small rollouts give a representative sample.

Internally we work in basis points (0..10000), so you can roll out at 0.01% granularity if you really need it.

Rolling out safely

A safe rollout schedule for an unknown-risk change:

0%   → enable rule + dogfood with overrides
1%   → 1 hour, watch error rate + p95
5%   → 4 hours
25%  → overnight
50%  → 1 day
100% → keep an eye on it for a week, then delete the flag

For a known-low-risk change you can compress this. For anything load-bearing (auth, billing, the data path) — don't. The whole point of a gradual rollout is to find the surprise before it's 100% of your traffic.

Bumping percentage never reshuffles

Going from 5% → 25% adds users to the bucket — the original 5% is still in. You won't see a user flap from true back to false as you ramp up. The only way to reshuffle is to bump the salt.

The salt

Each gate has its own salt. If you ever want to re-shuffle the rollout buckets without changing the percentage (say, to avoid the same 5% always being chosen for every gate you ever roll out), bump the salt:

shipeasy flags update my-feature --salt v2

That's rare. The default salt is fine for almost every case. The salt is also why two gates at 50% don't serve the same 50% of users — different salts, different buckets, statistically independent.

Combining rules + rollout

Rules are checked before rollout. Two practical implications:

  1. "100% of plan = pro" means all pro users get true. The rollout filter doesn't fight the rule.
  2. "5% of plan = pro" means 5% of pros, not "5% of everyone, then keep only pros". The bucket is computed against the pre-filtered population.

This is what you almost always want. If you ever need "this proportion of the entire user base, but only if they're pro" — that's an experiment and we have purpose-built tools for it.

Bucketing by something other than user_id

Default: bucketing key is user_id ?? anonymous_id. Override per-call:

flags.gate("new-team-ui", user, { bucketBy: "account_id" });

Or on the gate itself in the dashboard. Use this in B2B so all teammates of an account see the same variant — half a team on the new UI and half on the old is a confusing user experience and an unanalysable rollout.

Don't bucket by mutable attributes

Bucketing on email or plan means a user changes bucket whenever the attribute changes. They'll see the feature flicker on and off. Always bucket on something stable: user_id, account_id, or anonymous_id.

First-match-wins evaluation

When a gate has multiple rule groups (the dashboard's "OR an additional cohort" affordance), the SDK evaluates them top to bottom and takes the first match. Each group can have its own rollout percentage and bucketing key.

multi-group rule
[
  {
    "match": [{ "attr": "internal", "op": "eq", "value": true }],
    "rollout": 10000,
    "bucketBy": "user_id"
  },
  {
    "match": [{ "attr": "plan", "op": "eq", "value": "pro" }],
    "rollout": 500,
    "bucketBy": "account_id"
  }
]

Reads as: "100% of internal users; 5% of pro accounts (consistent across teammates); everyone else gets the default."

The order matters. If a user is both internal and pro, the first matching group wins — they get the 100% bucket, not the 5% one.

Overrides vs rules

Use rules for cohorts you can describe — "all internal users", "all pro accounts". They scale.

Use overrides for individual user IDs — QA accounts, customers reporting a specific bug, demos. They're the trump card and bypass everything else.

If you find yourself adding many overrides, that's a smell — promote them to a rule keyed on a internal: true or early_access: true attribute and you'll thank yourself when the QA team turns over.

Attributes that travel

Whatever attributes you pass to flags.gate() are also available to:

So define attributes once at the request boundary and pass the same user shape everywhere:

src/lib/auth.ts
export function buildEvalCtx(req: Request) {
  const session = getSession(req);
  return {
    user_id: session.user_id,
    plan: session.plan,
    country: geoFromReq(req),
    tenure_days: daysSince(session.created_at),
    app_version: req.headers.get("x-app-version") ?? "0.0.0",
  };
}

Debugging a rule

The dashboard's Test evaluator panel takes a JSON context and shows the full evaluation trace — which rule matched, which didn't, what bucket the user landed in. The CLI exposes the same:

shipeasy flags eval new-checkout-flow \
  --ctx '{"user_id":"u_4f2a","plan":"pro","country":"US"}'
gate    new-checkout-flow
result  true
trace   killswitch=off, enabled=on, override=–, rule[plan=pro]=match,
        rule[country in [US,CA,UK]]=match, bucket=2147 < 5000=true

Indispensable when a customer says "I should be in the beta, why am I not?".

NEXT

Promote to an experiment.

When you want a defensible answer (not just a ramp), turn the gate into an A/B test and let ShipEasy run the statistics.

Set rules
$shipeasy flags rules set my-flag --json '[…]'
Eval locally
$shipeasy flags eval my-flag --ctx '{…}'
Was this page helpful?✎ Edit on GitHub

On this page