LexQLexQ
Back to patterns
SaaSEligibilityOperationsTrustBeginner

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.

Sanghyun Park·June 29, 202613 min read12 min

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. Two questions follow, and the code answers neither. Support asks: why can this customer reach a feature their plan does not include? Finance asks: which features did the pro plan include on May 14, and which accounts reached one they were not entitled to.

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 flaw is structural, and it shows up in three places.

  • 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 14" is git blame across 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: the input the engine reads. plan_tier, feature_key.
  • Rule: one rule per feature — a condition and a BLOCK action carrying a reason string that names the tier the feature requires.
  • The default is allow. A BLOCK action runs only when its rule matches. A request that matches no rule proceeds. The denial is the exception, and the exception is the thing that gets written down.

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": "feature_key",
        "operator": "EQUALS",
        "value": "export",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "plan_tier",
        "operator": "NOT_IN",
        "value": ["enterprise"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "feature_key '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": "feature_key",
        "operator": "NOT_IN",
        "value": ["sso", "export", "api_access", "custom_roles"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "feature_key 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 feature_key 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.

Per-feature BLOCK rules plus the fail-closed catch-all in the console rules list

The entitlement is now data. Changing it is editing one rule in a draft version, and "what did the pro plan include on May 14" 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. Duplicate the live version, edit the one rule's entitled tiers, and the result is a candidate — the Target Version, in the console's terms — that carries no traffic.

This question is count-shaped, not sum-shaped, so the run needs no metric fact — the field is optional. includeRuleStats is enough: it reports each rule's match rate, and the export rule's match rate under the candidate, subtracted from the baseline's, is exactly the share of last month's requests the new entitlement would newly allow. That number is the extra support volume the change creates, and how many users a feature that was enterprise-only would newly reach — measured 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 conditions decide whether to ship. First, the newly allowed share — the export rule's baseline match rate minus its rate under the candidate — must sit inside the range product agreed to absorb. Second, every other feature's match rate must not move at all; the edit claimed to touch one rule, and the rule statistics verify that claim. During a simulation, external calls are mocked; the run reads historical data and writes nothing.

Impact Simulation comparing the pro-and-above candidate against the enterprise-only baseline

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": {
    "traceId": "7d92c4a1-...",
    "inputFacts": {
      "plan_tier": "free",
      "feature_key": "export"
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "is_blocked": true,
      "block_reason": "feature_key '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
      }
    ]
  }
}

Dry Run of the denied free-plan export request

mutatedFacts is empty — a BLOCK changes no fact. The denial arrives in generatedVariables: is_blocked is true, and block_reason carries the matched rule's reason string. Both keys are system-generated, and they exist only when a BLOCK action ran; when nothing denies, the keys are absent rather than false. The application contract follows directly: when is_blocked is true, refuse the feature — a 402 with the upgrade path, or a hidden button — and surface block_reason, which already names the tier the feature requires. The reason string is the upsell. Which expression the rule was evaluated against appears in the Execution Trace table in the Dry Run view above. Support's answer is the reason string; finance's answer is the trace — the rule, the version, the inputs, the timestamp.

Run the same export request from an enterprise account and every rule reads NO_MATCH with CONDITION_MISMATCH. Both maps come back empty, and the feature proceeds. The absence of is_blocked is the grant.

Dry Run of an enterprise export request matching no rule

Edge cases

The pattern is the single entitlement point, not the specific tiers. A few adjacent cases call for a deliberate choice.

  • A plan tier the rules do not list. A new trial tier starts arriving. Because each feature rule blocks when plan_tier is NOT_IN its 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: granting trial a 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 plan_tier or feature_key does not pass quietly. The engine throws an error naming the missing fact and never substitutes a default. The caller must treat an errored execution as not entitled — the engine refuses to guess, and the application 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 addon_sso — and write the sso rule 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 a feature rule added without updating the list would let that feature's requests fall through the catch-all. The simulation surfaces it: a live feature_key that 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

A candidate that clears both conditions goes to production with Deploy. The rule snapshot at deploy time is sealed with a hash and integrity-verified, and the record captures who deployed which version and when.

An entitlement change ships to all traffic at once, not through a gradual traffic split. An A/B test would route some requests through the candidate and the rest through the previous entitlement, so two accounts on the same plan — asking for the same feature — would receive different 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. The confidence to deploy comes from the Impact Simulation, not from a canary. What deploy adds is live confirmation — watch the live block rate per rule against the simulated match rates.

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.

Either of two signals means roll back immediately:

  • The changed rule's live block rate drifts from the simulated match rate. The production population differs from the historical window, and the blast radius that was signed off no longer holds.
  • Blocks appear in features the simulation reported untouched. The feature_key or plan_tier arriving in production does not look like the dataset.
Deployment detail for the candidate version

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 signup_before 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 returns the policy group to the previous version and leaves a deployment record, so the rollback itself stays in the audit trail. Once the candidate serves all traffic, the per-rule statistics are the standing answer to finance's question: which accounts reached which feature is a query over execution history, scoped to a rule and a version — not a log-archaeology project. And "what the pro plan included on May 14" is the version history plus the deployment record.

Ready to move decisions out of your deploy pipeline?

Try LexQ free — no credit card required.

Start Free