Skip to main content

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​

ValueMeaning
absentOn (the plan decides, as usual).
true / falseOn or off for everyone. false is a kill switch.
a functionDecides 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.
  • customer is the customer the entitlement belongs to for the call: the caller, or the org for an entitlement with scope: 'org'. Read customer.id, customer.type, customer.metadata, and customer.bucket(key).
  • context is 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 true is off. A rule that throws is off, and the error is logged (console.error in JavaScript).
  • Gates new usage only. check, allow, reserve, and features apply 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 < 25 is 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:

FieldMeaning
customerThe customer the entitlement belongs to for the call.
callerThe customer that made the call.
entitlementThe entitlement's name.
enabledWhether it was on.
planThe caller's plan.
event_dataThe 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​

GoalRule
Kill switchenabled: false
10% rolloutcustomer.bucket(entitlement) < 10
Beta listcustomer.metadata?.beta ?? false
Internal users firstcustomer.metadata?.staff ?? false
Only orgscustomer.type == 'org'
Per request (country, app version)context?.country == 'US'
A/B testcustomer.bucket(entitlement) < 50 with track: true

See Feature flags and rollouts for a complete walkthrough.