Centralizing Plan-Tier Feature Entitlement with BLOCK Rules
Per-tier feature checks move out of scattered conditionals into BLOCK rules, so every denied feature carries its reason.
The problem
Most SaaS products sell the same software at several plan tiers, and the tiers differ by which features each one unlocks. A free account gets the core product. A pro account adds single sign-on and API access. An enterprise account adds audit-log export and custom roles. Which feature belongs to which tier is a business decision — product and sales own it, and they change it on their schedule, not engineering's.
In code, that decision starts as one branch: if (plan == PRO). Then it spreads. The API gate checks it. The web UI hides a button behind the same check. The billing webhook re-checks it when a subscription changes. A year later a new feature component copies the same check with the tiers that were current that quarter. The same entitlement now lives in the API, the client, the webhook, and a dozen feature components, and a packaging change has to land in all of them at once.
It does not. A free-plan account reaches an enterprise-only export endpoint that still answers, because one gate was never updated. Support opens a ticket asking how that account reached a feature its plan does not include. Finance wants something the ticket cannot give: which features the pro plan included on May 6, and which accounts reached one they were not entitled to. Neither answer is in the code.
The question this pattern answers: how does a team keep one entitlement decision per feature in force across every gate, and answer months later which plan included which feature, and why a given request was allowed or denied.
The naive approach
The first version puts the entitlement next to the check, as constants in a gate class.
public class FeatureGate {
// Which tier unlocks which feature — owned by product and sales.
private static final Set<Plan> SSO_PLANS = Set.of(Plan.PRO, Plan.ENTERPRISE);
private static final Set<Plan> EXPORT_PLANS = Set.of(Plan.ENTERPRISE);
// The web UI keeps its own copy of these sets to show or hide
// buttons. The billing webhook re-checks them on plan change.
// A reporting service added later hard-coded the tiers it knew then.
public void requireFeature(String feature, Account account) {
Plan plan = account.getPlan();
switch (feature) {
case "sso" -> require(plan, SSO_PLANS);
case "export" -> require(plan, EXPORT_PLANS);
// No default branch. A feature key this switch does not know
// falls through, and the caller proceeds as if entitled.
}
}
private void require(Plan plan, Set<Plan> entitled) {
if (!entitled.contains(plan)) {
throw new FeatureNotEntitledException("Plan not entitled to feature");
}
}
}
It works at small scale, and each copy was correct the day it shipped. The defect is structural, and a packaging change exposes it in three ways.
- The entitlement lives in more than one place. The service holds the sets, the UI holds a copy for show-or-hide, the webhook holds another, the reporting service holds the tiers from its launch quarter. A packaging change is now a synchronized deploy across codebases, and a miss is silent — nothing fails, one gate is merely wrong.
- Changing entitlement is a release. Product sets effective dates; constants ship on release trains. The entitlement a customer experiences depends on which deploy went out, and the history of who-got-what is the git history of several repositories.
- A denial leaves no queryable record. The exception lands in a log line. "Every free account that reached the export feature in May" is a log-archaeology project, and "what the pro plan included on May 6" is
git blameacross repos.
Defining the pattern
The fix is to make the entitlement decision a single call. Every gate — the API, the UI, the billing webhook — asks the same policy group, and the entitlement matrix exists exactly once.
First, a distinction the scattered code erases. A feature flag and a feature entitlement look identical in code — both are a boolean guarding a branch — but they answer different questions and change on different clocks. A flag is a release control: is this code path turned on yet? It belongs in the deploy pipeline and flips when engineering ships. An entitlement is a business decision: is this customer's plan allowed to use this feature? It belongs to product and billing and changes when packaging changes. This pattern moves the entitlement out of code. It does not move release flags; those stay where deploys live.
In LexQ terms, the entitlement maps to three concepts.
- Fact: what every gate sends the engine on each check.
planTier,featureKey. - Rule: one rule per feature — a condition and a
BLOCKaction carrying areasonstring that names the tier the feature requires. - The default is allow. Nothing denies unless a rule's own condition holds, so a request the entitlement rules leave untouched reaches its feature. Both outcomes land in execution history, and only the denial arrives carrying a reason.
There is no mutex group here. A request names one feature and carries one plan, so the conditions partition the input — at most one feature rule can match a given request.
{
"name": "Block: export requires enterprise",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "featureKey",
"operator": "EQUALS",
"value": "export",
"valueType": "STRING"
},
{
"type": "SINGLE",
"field": "planTier",
"operator": "NOT_IN",
"value": ["enterprise"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "featureKey 'export' requires the enterprise plan"
}
}
],
"isEnabled": true
}
The rule above is for one feature, export. Every other feature gets a rule of the same shape — sso, api_access, and so on — each naming its own entitled tiers. One more rule handles any feature key the others do not recognize.
{
"name": "Block: unknown feature (fail closed)",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "featureKey",
"operator": "NOT_IN",
"value": ["sso", "export", "api_access", "custom_roles"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "featureKey is not a known entitlement"
}
}
],
"isEnabled": true
}
Default-allow is what lets an entitled request pass without a rule for it — and the same default turns an unknown featureKey into a silent grant. For entitlement, that default is a revenue leak: a new premium feature shipped before its rule exists would be free on every plan. This rule converts that accident into a decision — an unmodeled feature is denied, with a reason. The list of known features and the feature rules are edited in the same draft version and verified together, so adding a feature is one reviewed change, not a constant copied into the next service.
The entitlement is now data. Changing it is editing one rule in a draft version, and "what did the pro plan include on May 6" is answered by version history, not git blame.
Impact Simulation strategy
Product decides to move the export feature down a tier — from enterprise-only to pro and above — to make the pro plan close more deals. Before that change reaches a live request, it has a measurable blast radius: the share of real requests that would flip from denied to allowed. Cloning the live version and widening that one rule's entitled set produces a candidate (the Target Version, in the console's terms) that serves no account yet.
Nothing here needs summing, so the run declares no metric fact; that field is optional and stays empty. includeRuleStats does the whole job: it puts a match rate on every rule, and the export rule's baseline match rate minus its candidate match rate is the share of last month's traffic that flips from denied to allowed. That share counts the users a formerly enterprise-only feature would newly reach, and the support load arriving with them, both readable before deploy.
lexq analytics simulation start --json '{
"policyVersionId": "<candidate-version-id>",
"dataset": {
"type": "HISTORICAL",
"source": "EXECUTION_LOGS",
"from": "2026-05-01",
"to": "2026-05-31"
},
"options": {
"baselinePolicyVersionId": "<baseline-version-id>",
"includeRuleStats": true,
"maxRecords": 50000
}
}'
Two numbers in the rule statistics settle whether the retier ships. The first is the newly allowed share: the export rule matches less often under the candidate, and the size of that fall against the baseline has to land inside the band product agreed to absorb. The second is every other feature rule, whose match rate has to read identically on both sides, because the edit touched one rule and unchanged neighbors are the proof. The run itself only replays May's logged requests, so no live account's entitlement moves while it executes.
If no production traffic exists yet, upload a representative dataset instead — requests spanning every plan and every feature key, including a few unknown keys to exercise the catch-all — and run the same comparison. The mechanics of comparing a candidate against a baseline are the subject of Testing a Rule Change Before Deploy with Impact Simulation.
Decision Trace output
Run the case from the support report: a free account requesting the export feature, with Dry Run.
{
"result": "SUCCESS",
"data": {
"inputFacts": {
"planTier": "free",
"featureKey": "export"
},
"mutatedFacts": {},
"generatedVariables": {
"isBlocked": true,
"blockReason": "featureKey 'export' requires the enterprise plan"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Block: export requires enterprise",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "Block: sso requires pro",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Block: api_access requires pro",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Block: custom_roles requires enterprise",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Block: unknown feature (fail closed)",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
}
]
}
}
A BLOCK writes no fact, so mutatedFacts stays empty. The denial shows up entirely in generatedVariables, as isBlocked with the value true next to a blockReason copied from the rule that matched. Neither key exists unless a BLOCK executed on that run, so an entitled request comes back with the keys missing rather than with a false in each. That leaves the caller one branch: on isBlocked, refuse the feature with a 402 and the upgrade path, or hide the button, and pass blockReason through untouched, since it already names the tier the feature requires. The reason string is the upsell. The Execution Trace table in the Dry Run view above shows which expression the rule was evaluated against. Support closes the ticket with the reason string; finance needs what sits behind it, which is the rule that fired, the version that held it, the facts as they arrived, and the timestamp.
Send the same export request from an enterprise account: no rule matches, so every entry in decisionTraces reads NO_MATCH with CONDITION_MISMATCH, and neither mutatedFacts nor generatedVariables holds anything. The export proceeds. A missing isBlocked key is the grant.
Edge cases
The pattern is the single entitlement point, not the specific tiers. Packaging produces neighboring cases, and each one needs an explicit call.
- A plan tier the rules do not list. A new
trialtier starts arriving. Because each feature rule blocks whenplanTierisNOT_INits entitled set, an unlisted tier is denied every gated feature by default — fail closed. That is the safe default, but it is still a decision: grantingtriala feature means adding it to that feature's entitled set, in one draft, and the simulation shows the resulting drop in each affected rule's match rate. - A missing fact. A payload without
planTierorfeatureKeydoes not pass quietly. The engine throws an error that names the absent fact and substitutes nothing in its place. Treat an errored execution as not entitled: the engine declines to guess, and the caller must not guess on its behalf. - A non-hierarchical add-on. Some features sell as add-ons independent of base tier — a pro account that separately bought the SSO add-on. Tier alone cannot express this. Model the add-on as its own fact (a boolean like
addonSso) and write thessorule against that fact instead of the tier. Entitlement is not always a ladder. - The known-feature list drifting from the rules. The catch-all's known-feature list must stay in sync with the feature rules. Both live in one version and are reviewed together, but the two drift in opposite directions with opposite results. A feature rule added without adding its key to the list leaves the catch-all matching every request for that feature, entitled plans included. A key added to the list without its feature rule leaves the feature ungated. The simulation surfaces it: a live
featureKeythat no rule and no list entry covers shows up as an unexpected catch-all match. Run the simulation on real keys before deploy. - A quantity, not a yes/no. This pattern answers whether a plan includes a feature. "How many seats does the plan include" or "how many exports per month" is a count against a limit — an accumulating fact and a threshold, a different pattern with different failure modes. That shape is a usage limit, closer to Enforcing KYC-Tiered Transfer Limits with BLOCK Rules than to this one.
Production rollout
Both numbers clear, and Deploy puts the target version in front of live requests. The entitlement matrix ships as a hashed snapshot with its integrity verified, and the deployment record holds the version, its deployer, and the timestamp.
Every gate switches to the new entitlement in the same moment. A traffic split is the wrong instrument here: it would send part of the requests to the candidate and the rest to the entitlement it replaces, so two accounts on the same plan, asking for the same feature, would get opposite answers. A pricing experiment tolerates that; an entitlement gate does not, because a customer who can see a feature their teammate on the identical plan cannot is a support ticket and a crack in the paywall, not a safeguard. A canary would add nothing the Impact Simulation has not already measured, which is why the retier ships on the simulation's numbers. Deploy contributes one further reading: the block rate each rule produces on production traffic, set beside the rate the simulation forecast.
This is also where the flag-and-entitlement distinction pays off. A release rollout — shipping new code to 5 percent, then 25, then the rest — is exactly the gradual split an entitlement must avoid, and it stays in the deploy pipeline, gating code readiness, not plan access. The two never share a control surface.
Two signals in the live block rates call for an immediate rollback:
- The changed rule's live block rate drifts from the simulated match rate. The accounts arriving now are not May's population, and the newly allowed share product signed off on no longer holds.
- Blocks appear in features the simulation reported untouched. The
featureKeyorplanTierarriving in production does not look like the dataset.
Softening a change is its own case. Putting a previously free feature behind a tier blocks accounts that have used it for months, and an atomic deploy makes that abrupt. The answer is not a percentage canary, which would hand identical accounts different answers. It is an explicit rule: grandfather the existing users with a fact — a grandfathered flag, or a signupBefore date — so every account's answer stays deterministic and shows up in its trace. The softening is itself a recorded decision, not a gradual accident.
A rollback re-deploys the previous entitlement version, which puts the reversal itself in the deployment record. With the candidate serving all traffic, finance's question stops being log archaeology and turns into a query: per-rule statistics over execution history, scoped to one rule and one version, return which accounts reached which feature. "What the pro plan included on May 6" reads out of the version history and the deployment record. A gate that answers slower after the deploy raises a narrower question: which feature rule spent the time. The per-rule breakdown is in Isolating a slow rule on a hot path.
Ready to move decisions out of your deploy pipeline?
Try LexQ free — no credit card required.
Start Free