Gates
Boolean feature flags with targeting rules, percentage rollouts, kill-switches, and per-user overrides — evaluated locally in your SDK with zero per-request cost.
A gate is a single boolean answer for a single user. It is the unit of "should I do the new thing?" in your code. Gates are evaluated locally — the SDK keeps the bundle in process and the call is a hash table lookup, not a network round trip.
Use a gate when the answer is yes/no. For typed payloads (strings, numbers, JSON), reach for a dynamic value. For yes/no with measurement attached, reach for an experiment.
Anatomy of a gate
enabled — master switch→
The on/off bit. When false, the gate returns false for everyone, ignoring rules.
Targeting rules and rollout are preserved.
killswitch — emergency off→
Same effect as enabled = false but separate, so you can disable a feature during an
incident without losing its targeting config.
rules — targeting→
Optional list of { attr, op, value } predicates. ANDed. A user must match every
rule (and the rollout) to get true.
rolloutPct — percentage→
0..10000 internally (UIs show 0–100%). Bucketing is deterministic by user ID and
salt.
overrides — per-user→
Force true or false for specific user IDs. Bypasses everything. Useful
for QA + dogfooding.
salt — hash salt→
Per-gate salt fed into the bucketing hash. Bump it and rollout buckets re-shuffle — useful when rolling out the same feature twice.
Evaluation order
For a request gate(name, user):
1. killswitch ON → false
2. enabled OFF → false
3. user.user_id ∈ overrides[true] → true
4. user.user_id ∈ overrides[false] → false
5. any rule fails → false
6. murmur3(salt + user_id) % 10000 < rolloutPct → true
7. otherwise → falseThat order matters: rules are evaluated before the rollout. "100% of plan = pro" means 100% of pro users, not the same 100% bucket filtered down to pros. The rollout filter never fights a targeting rule.
Same user, same gate, same answer — every time, on every server, in every language SDK. No flicker, no "why does this user see different things on different requests", no need to persist assignments anywhere. The hash is the persistence.
API
Server SDK
import { configureShipeasy, flags } from "@shipeasy/sdk/server";
configureShipeasy({ apiKey: process.env.SHIPEASY_SERVER_KEY! });
await flags.init();
const enabled = flags.gate("new-checkout-flow", {
user_id: "u_4f2a",
plan: "pro",
country: "US",
});Browser SDK
import { configureShipeasy } from "@shipeasy/sdk/client";
const client = configureShipeasy({ apiKey });
await client.identify({ user_id, plan, country });
if (client.getFlag("new-checkout-flow")) {
// …
}gate() / getFlag() is synchronous. If you call it before init() resolves, you get false and a dev-mode warning. Always await flags.init() once at boot.
user_id for deterministic bucketing. Add any attributes used by your targeting rules.user_id. Use account_id for B2B cohort consistency.The return is a plain boolean. There is no async, no Promise, no fetch — the bundle is in memory.
Killswitch
The killswitch is a separate field from enabled so you can keep targeting and rollout intact while turning the feature off. Three places to flip it:
- Dashboard: red Killswitch chip on the gate row.
- CLI:
shipeasy flags disable <name> --killswitch(without--killswitchit togglesenabledinstead). - API:
PATCH /api/flags/<name>with{ "killswitch": true }.
A killed gate returns false everywhere within the SDK's next poll. Re-enabling restores the full prior state — overrides, rules, rollout %, salt, all of it. Treat the killswitch as your incident lever; treat enabled as your "feature is shipped, deactivated cleanly" lever.
A killed gate goes false within the next poll, not instantly. With the default 30s poll a worst-case worker still serves the new path for ~30s after you hit the switch. For sub-second cutoff, use the Pro 10s poll or the Enterprise streaming push.
Overrides
In the dashboard, expand a gate and add user IDs under Overrides → Always on or Always off. Overrides:
- Bypass
enabled,killswitch, all rules, and the rollout. - Are scoped to
user_idonly (no attribute matching — it's a literal set lookup). - Are great for: QA accounts, internal dogfooders, customer-success demos, repro of a bug a single customer is hitting.
// dashboard JSON shape:
{
"name": "new-checkout-flow",
"overrides": {
"always_on": ["u_qa1", "u_qa2", "u_demo"],
"always_off": ["u_legacy_partner"]
}
}If you need a percentage-based "always-on for cohort X", use a targeting rule on a custom attribute (internal: true) instead. Overrides scale linearly per-row in KV; rules are O(1).
Naming
- kebab-case:
new-checkout-flow, notnewCheckoutFlow. - Describe the change, not the abstract feature:
enable-redis-poolages better thanredis-pool(which sounds like a permanent feature, not a temporary gate). - Prefix by area for grouping:
checkout-,nav-,infra-. - Avoid double-negatives: prefer
show-banneroverhide-banner.
Cleaning up
Stale flags are technical debt — once a feature is fully launched, delete the flag and the dead code. The CLI helps:
# Every gate currently at enabled: true, rollout: 100%
shipeasy flags list --json \
| jq '.[] | select(.enabled == 1 and .rolloutPct == 10000) | .name'These are candidates for removal. Run a shipeasy flags validate ./src after the deletion to catch any code references that need to be ripped out.
The dashboard also surfaces a Cleanup view that lists gates which have been at 100% (or 0%) for more than 30 days, with a one-click PR-creation button if you've connected a GitHub app.
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), bump the salt:
shipeasy flags update my-feature --salt v2That'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, independently distributed.
Audit log
Every write to a gate is recorded in the audit log: who, when, from what (dashboard, CLI, API), and the diff. Find it under Configs → Gates → <gate> → History. Each row links to a one-click revert.
Limits
| Plan | Gates per project | Rules per gate | Overrides per gate |
|---|---|---|---|
| Free | 50 | 5 | 100 |
| Pro | 500 | 20 | 10,000 |
| Enterprise | Unlimited | Unlimited | Unlimited |
If you hit a limit, audit for stale flags first; those numbers are very generous if you delete what you ship.
Add targeting rules.
A flag with no rules is a kill-switch. Add an attribute predicate and you've got the rules engine — "100% of plan=pro in country=US" in one line.