Feature rules
An entitlement with no limit is a feature flag: on for every plan that lists it. Three more pieces decide who has a feature right now:
enabled: a rule on the entitlement. A kill switch, a percentage rollout, a beta list, or a rule per call.- Gives and takes: per-customer changes to whether a customer has an entitlement. See Customers.
track: an event each time the feature is checked, to measure a rollout or an experiment.
These work on every entitlement, metered or not.
#[type]
Rollouts: {
#[static]
/// On for customers in the beta, and for a quarter of everyone else.
fn beta_or_quarter(customer: Customer, entitlement: str, context?: obj) -> bool {
(customer.metadata?.beta ?? false) || customer.bucket(entitlement) < 25
}
}
policy: {
plans: {
pro: {
default: true
entitlements: {
// A shared rule
ai_assistant: { enabled: <Rollouts>.beta_or_quarter, track: true }
// A rule of its own
new_checkout: {
fn enabled(customer: Customer, entitlement: str, context?: obj) -> bool {
context?.country == 'US'
}
}
// A kill switch
old_editor: { enabled: false }
sso: {}
}
}
}
}
The enabled rule
| Value | Meaning |
|---|---|
| absent | On (the plan decides, as usual). |
true / false | On or off for everyone. false is a kill switch. |
| a function | Decides per call: (customer: Customer, entitlement: str, context?: obj) -> bool. |
- Inline or shared. Write the function on the entitlement (
fn enabled(...)), or point at one in a#[type](enabled: <Rollouts>.beta_or_quarter) to share a rule across features and plans. The rule is told which entitlement it's deciding, so one rule can roll out many features. customeris the customer the entitlement belongs to for the call: the caller, or the org for an entitlement withscope: 'org'. Readcustomer.id,customer.type,customer.metadata, andcustomer.bucket(key).contextis the call's event data (Ex.{ country: 'US' }), or null. Default every field you read with??.- Only narrows. The rule runs after the plan, gives, and takes. It can turn a feature off, but it can't hand out one the customer doesn't have. A bug in a rollout rule can never give away a paid feature.
- Fails closed. Anything but
trueis off. A rule that throws is off, and the error is logged (console.errorin JavaScript). - Gates new usage only.
check,allow,reserve, andfeaturesapply it.settle(usage that already happened) and decrements always go through, so switching a feature off mid-call never loses usage. - Keep it cheap and pure. Rules run on every check. Read and compare; don't change the policy.
Changes to enabled take effect the moment the policy changes: on the next call in your process, or on every engine
when a Cloud policy is published.
Percentage rollouts
customer.bucket(key) is a number from 0 to 99 for a customer and a key (usually the entitlement's name):
- Stable. The same customer and key always get the same number, on every call, server, and SDK.
bucket < 25is always the same quarter of customers. - Widens cleanly. Raising 25 to 50 keeps the first quarter and adds another.
- Independent per key. Each key splits customers differently, so the same customers aren't first in line for every feature.
Never use a random number in a rule: a feature would flip on and off between calls.
The number is hash('<key>:<customer id>') % 100, where the hash is FNV-1a (32-bit, over UTF-8 bytes) followed by
MurmurHash3's 32-bit finalizer. Any server can compute the same number, Ex. to know a customer's rollout group
without asking the engine:
function bucket(key: string, customerId: string): number {
let h = 0x811c9dc5; // FNV-1a, 32-bit
for (const byte of new TextEncoder().encode(`${key}:${customerId}`)) {
h = Math.imul(h ^ byte, 0x01000193) >>> 0;
}
h ^= h >>> 16; // MurmurHash3 fmix32
h = Math.imul(h, 0x85ebca6b) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35) >>> 0;
h ^= h >>> 16;
return (h >>> 0) % 100;
}
Targeting customers
Per-customer targeting (a beta list, design partners) is customer metadata, which a rule reads:
await limitr.createCustomer('ada', 'pro', 'user', 'Ada', null, null, { beta: true });
Update it later with setCustomer. For a feature outside a customer's plan, give it
instead: a rule can only narrow.
Listing a customer's features
Lists the entitlements a customer can use right now: its plan's, less any taken, plus any given, with enabled
applied. Send it to your front end to show and hide features:
const features = await limitr.features(userId, { country: 'US' });
// ['ai_assistant', 'new_checkout', 'sso']
It's about access, not capacity: a metered entitlement that's used up is still listed. allow and check answer
whether there's room. An unknown customer returns null.
Tracking checks
track: true sends a feature-checked event each time the entitlement is checked by check, allow, or
features:
| Field | Meaning |
|---|---|
customer | The customer the entitlement belongs to for the call. |
caller | The customer that made the call. |
entitlement | The entitlement's name. |
enabled | Whether it was on. |
plan | The caller's plan. |
event_data | The call's event data. |
Joined with the usage events, that's who saw a feature and what they did next: enough to measure a rollout or an
A/B test (a rollout at 50). Checks are frequent, so only track what you're measuring. feature-checked also reaches
policy notifications.
Recipes
| Goal | Rule |
|---|---|
| Kill switch | enabled: false |
| 10% rollout | customer.bucket(entitlement) < 10 |
| Beta list | customer.metadata?.beta ?? false |
| Internal users first | customer.metadata?.staff ?? false |
| Only orgs | customer.type == 'org' |
| Per request (country, app version) | context?.country == 'US' |
| A/B test | customer.bucket(entitlement) < 50 with track: true |
See Feature flags and rollouts for a complete walkthrough.