Entitlements for Subscription Plans: Data Model, Caching, and Stale Permissions

Move plan checks into data, cache them safely, and enforce seat limits where they cannot be exceeded. A lab shows how long one lost invalidation message keeps a server wrong.

Most products start with plan checks written straight into the code: if tenant.Plan == "pro". That works until sales sells one customer a feature from a higher plan, product splits a feature into an add-on, or a pricing change needs old customers to keep what they had. Then the checks multiply, and changing a price becomes a code change across several services.

An entitlements service moves those decisions into data: which tenant may use which feature, and how much of it. This guide covers the data model, how checks are resolved, how to cache them without serving stale permissions for an hour, and how to enforce numeric limits such as seats. The lab simulates 20 application servers caching entitlements and shows what one lost invalidation message does.

Feature flags and entitlements are different things

Feature flag Entitlement
Question it answers Is this code path turned on? Has this customer paid for, or been granted, this capability?
Who changes it Engineers, during rollout or an incident Billing, sales, or support, following a contract
Lifetime Temporary; removed when rollout ends As long as the product sells the feature
Typical targeting Percentage of users, internal staff, region Tenant, plan, add-ons, contract overrides
Cost of a wrong answer A feature shows early or late A customer is denied something they paid for, or gets something they did not

Martin Fowler's article on feature toggles calls the second kind "permissioning toggles" and notes that they live much longer than release toggles. Many teams use one flag system for both; if you do, keep entitlement rules in their own namespace with their own change process, because they are commercial commitments. OpenFeature, a vendor-neutral flag evaluation API that became a CNCF incubating project in November 2023, can sit in front of either.

A data model that survives pricing changes

Separate three things:

  1. Capabilities, named by what they do: saml_sso, audit_log_export, max_seats, retention_days. Some are on/off; some are numbers.
  2. Plans, which bundle capabilities with default values. Version them (pro_2025, pro_2026) instead of editing them, so existing customers keep the bundle they bought.
  3. Overrides for one tenant: a feature granted during a sales trial, a higher seat limit in a negotiated contract. Give each override an owner, a reason, and an end date.

Code then asks about capabilities, never plans: can(tenant, "saml_sso"), limit(tenant, "max_seats"). Resolution is: an active override wins; otherwise the tenant's plan version decides; otherwise deny or return zero.

Store a single entitlement version number per tenant that increases whenever anything affecting that tenant changes: its plan, an override, an add-on. It costs one integer and makes caching much easier to reason about.

Lab: how stale can a cached entitlement get?

Entitlement checks happen on many requests, so services cache them in memory. The usual invalidation is a Redis Pub/Sub message telling every server to drop a tenant's entry. Redis documents Pub/Sub as at-most-once: if a subscriber is disconnected when a message is sent, that message is lost.

The program simulates one hour on 20 servers, each serving 200 checks per second for random tenants out of 2,000. There are 300 entitlement changes. Each invalidation reaches each server 5 to 50 ms later, except that 0.5% of deliveries are lost. It compares three strategies: messages only; messages plus a 60-second cache lifetime; and messages plus a poll every 5 seconds of a global change counter, after which the server drops tenants changed since its last poll. Run it with go run .; it needs only the standard library and takes a few seconds.

// Lab: how stale can an in-process entitlement cache get?
//
// 20 application pods cache each tenant's entitlements in memory. Over one
// simulated hour, 300 plan changes happen. Each change publishes an
// invalidation message; each pod receives it after 5-50 ms, except that
// 0.5% of deliveries are lost (for example during a subscriber reconnect).
// Every pod serves 200 entitlement checks per second for random tenants.
//
// Strategies:
//
//	A. invalidation messages only
//	B. invalidation messages + 60 s cache TTL
//	C. invalidation messages + each pod polls a global "entitlements version"
//	   every 5 s and refreshes tenants changed since the version it last saw
//
// A check is stale if it returns entitlements older than the latest change.
// Simulated time in milliseconds with a fixed seed.
package main

import (
	"fmt"
	"math/rand/v2"
	"sort"
)

const (
	pods       = 20
	tenants    = 2000
	hourMs     = 3_600_000
	changes    = 300
	checksPerS = 200
	lossRate   = 0.005
)

type change struct {
	at, tenant, version int
}

func main() {
	rng := rand.New(rand.NewPCG(11, 3))
	var cs []change
	latest := make([]int, tenants) // version per tenant
	for i := 0; i < changes; i++ {
		cs = append(cs, change{at: rng.IntN(hourMs), tenant: rng.IntN(tenants)})
	}
	sort.Slice(cs, func(i, j int) bool { return cs[i].at < cs[j].at })
	for i := range cs {
		latest[cs[i].tenant]++
		cs[i].version = latest[cs[i].tenant]
	}
	// delivery[p][i] = arrival time at pod p of message i, or -1 if lost
	delivery := make([][]int, pods)
	for p := range delivery {
		delivery[p] = make([]int, len(cs))
		for i, c := range cs {
			if rng.Float64() < lossRate {
				delivery[p][i] = -1
			} else {
				delivery[p][i] = c.at + 5 + rng.IntN(46)
			}
		}
	}
	type check struct{ at, pod, tenant int }
	var checks []check
	for p := 0; p < pods; p++ {
		for t := 0; t < hourMs; t += 1000 / checksPerS {
			checks = append(checks, check{t, p, rng.IntN(tenants)})
		}
	}
	sort.Slice(checks, func(i, j int) bool { return checks[i].at < checks[j].at })
	lost := 0
	for p := range delivery {
		for _, d := range delivery[p] {
			if d < 0 {
				lost++
			}
		}
	}
	fmt.Printf("%d changes, %d pods, %d checks, %d of %d invalidation deliveries lost\n\n",
		len(cs), pods, len(checks), lost, len(cs)*pods)

	for _, s := range []struct {
		name      string
		ttl       int // 0 = no TTL
		pollEvery int // 0 = no version polling
	}{
		{"A  messages only", 0, 0},
		{"B  messages + 60 s TTL", 60_000, 0},
		{"C  messages + 5 s version poll", 0, 5_000},
	} {
		// cache[p][tenant] = cached version, cachedAt
		type entry struct{ version, at int }
		cache := make([]map[int]entry, pods)
		for p := range cache {
			cache[p] = map[int]entry{}
		}
		truth := make([]int, tenants)
		ci := 0
		type ev struct{ at, pod, msg int }
		var events []ev
		for p := range delivery {
			for i, d := range delivery[p] {
				if d >= 0 {
					events = append(events, ev{d, p, i})
				}
			}
		}
		sort.Slice(events, func(i, j int) bool { return events[i].at < events[j].at })
		ei := 0
		lastPoll := make([]int, pods)
		seenChange := make([]int, pods) // index into cs already applied by polling
		stale, worst := 0, 0
		staleSince := map[[2]int]int{}
		for _, k := range checks {
			for ci < len(cs) && cs[ci].at <= k.at {
				truth[cs[ci].tenant] = cs[ci].version
				ci++
			}
			for ei < len(events) && events[ei].at <= k.at {
				e := events[ei]
				delete(cache[e.pod], cs[e.msg].tenant)
				ei++
			}
			if s.pollEvery > 0 && k.at-lastPoll[k.pod] >= s.pollEvery {
				lastPoll[k.pod] = k.at
				for j := seenChange[k.pod]; j < ci; j++ { // tenants changed since last poll
					delete(cache[k.pod], cs[j].tenant)
				}
				seenChange[k.pod] = ci
			}
			e, ok := cache[k.pod][k.tenant]
			if !ok || (s.ttl > 0 && k.at-e.at >= s.ttl) {
				e = entry{truth[k.tenant], k.at} // miss: load from the database
				cache[k.pod][k.tenant] = e
			}
			key := [2]int{k.pod, k.tenant}
			if e.version != truth[k.tenant] {
				stale++
				if _, ok := staleSince[key]; !ok {
					staleSince[key] = k.at
				}
				if d := k.at - staleSince[key]; d > worst {
					worst = d
				}
			} else {
				delete(staleSince, key)
			}
		}
		fmt.Printf("%-32s stale checks: %6d   longest observed staleness: %7.1f s\n", s.name, stale, float64(worst)/1000)
	}
}

Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64):

300 changes, 20 pods, 14400000 checks, 27 of 6000 invalidation deliveries lost

A  messages only                 stale checks:   5090   longest observed staleness:  3464.7 s
B  messages + 60 s TTL           stale checks:     85   longest observed staleness:    38.2 s
C  messages + 5 s version poll   stale checks:     25   longest observed staleness:     1.6 s

What the output shows

With messages only, a lost message lasted almost the whole hour. Twenty-seven of 6,000 deliveries were lost. Each one left a server answering with old entitlements until the end of the run, 3,464 seconds in the worst case observed. In production that lasts until the server restarts. A customer who upgraded keeps seeing "upgrade required" on one server out of twenty, which looks like a random bug and is very hard to reproduce.

A 60-second lifetime bounded the damage, to 38 seconds observed, and reduced stale answers from 5,090 to 85.

Polling a version every 5 seconds bounded it further, to 1.6 seconds, with one small read per server every 5 seconds rather than one per check. The remaining stale answers mostly fell inside the normal 5 to 50 ms delivery delay.

Staleness is measured only when a check happens, so these are observed worst cases, not exact upper bounds. The 0.5% loss rate is an assumption; measure your own subscriber reconnects and missed messages.

How fast is the cache itself?

An in-memory lookup is fast enough that it rarely matters. The benchmark in the encryption keys guide measured a hit at 37 to 58 ns on one goroutine on a laptop CPU, for both sync.Map and a map behind a read-write mutex. The difference between the two matters less than whether the value is correct. Go's documentation notes that sync.Map is optimized for keys that are written once and read many times or for goroutines working on disjoint keys; an entitlement cache that is invalidated often is neither, so a plain map with a mutex is a reasonable default.

Checking a version on the hot path

If one tenant's entitlements must take effect within a second everywhere, you can store the tenant's entitlement version in Redis and read it on each check, comparing it with the cached version. That costs one small network read per check, which is what the in-memory cache was meant to avoid, so most systems use the periodic poll from the lab instead and apply hot-path checks only for the few capabilities where a delay is unacceptable.

Numeric limits: enforce them where the data is

"At most 50 seats" is a different problem from "has SSO". Checking a cached limit and then inserting a user is a race: two administrators inviting users at the same time can both see 49 and both succeed. Enforce the limit in the same transaction that adds the seat, in the database that owns users. One way, as a PostgreSQL sketch that was not executed for this guide:

-- seats_used is maintained in the same transaction as membership changes.
ALTER TABLE tenants ADD CONSTRAINT seats_within_limit CHECK (seats_used <= seat_limit);

BEGIN;
UPDATE tenants SET seats_used = seats_used + 1 WHERE id = $1;  -- fails the CHECK at 51
INSERT INTO memberships (tenant_id, user_id, role) VALUES ($1, $2, 'member');
COMMIT;

The row update serializes concurrent invitations for one tenant, and the constraint makes the limit impossible to exceed regardless of what any cache says. Keep the limit itself in the tenant row, updated from the entitlement service whenever the plan or an override changes. A Redis counter can show usage quickly in the interface, but it should not be the thing that decides.

When the entitlement service is unavailable

Decide per capability, in advance, whether to allow or deny when no fresh answer is available. Reasonable defaults:

  • Keep serving the last known answer from the in-memory cache, and log that it is stale.
  • Allow continued use of features the tenant already had, such as viewing existing data, because denying them breaks customers who have paid.
  • Deny new actions that create cost or commitments, such as starting an expensive export or adding paid seats, until a fresh answer is available.

Write the choices next to each capability's definition so they are reviewed when the capability is.

Where to start

Search your code for checks against plan names and replace them with capability checks, even if the first implementation simply maps plan to capabilities in code. Then add a per-tenant entitlement version, cache with a bounded lifetime, and poll the version on a short interval. Move seat and quota enforcement into the transaction that consumes them. The webhook guide covers how plan changes from your billing provider should reach the entitlement data, and the proration guide covers when a plan change should take effect.

Frequently Asked Questions

Should entitlements live in the billing provider?

The provider should be the source of what the customer bought, and your entitlement data should be derived from it plus your own overrides. Checking the provider on every request adds latency and an external dependency to every feature check.

How do I handle a customer on an old plan version?

Keep the old plan version and its capabilities as data. The customer stays on it until they change plans, at which point they move to the current version. Avoid editing a published plan version in place.

Can I use my feature flag service for entitlements?

Yes, if it can target by tenant, keeps an audit trail of changes, and lets billing or support manage the rules with appropriate permissions. Keep entitlements in a separate namespace from release flags so cleanup of old flags never removes a paid feature.

What should the API return when a capability is missing?

A 403 with a stable error code and the capability name lets the client show an upgrade prompt. Do not include internal plan identifiers or prices in the error.

Is Redis Streams better than Pub/Sub for invalidation?

Streams persist messages, so a reconnecting subscriber can read what it missed, which removes the lost-message problem. You still need a bounded cache lifetime or version poll for servers that are down longer than the stream retention.

Sources

Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.

  • Feature Toggles (aka Feature Flags) (Pete Hodgson, martinfowler.com) • Link checked 2026-11-03

    Categories of toggles, including long-lived permissioning toggles.

  • OpenFeature: Introduction (OpenFeature project (CNCF incubating)) • Link checked 2026-11-03

    A vendor-neutral API for flag evaluation.

  • OpenFeature project page (Cloud Native Computing Foundation) • Link checked 2026-11-03

    Accepted June 17, 2022; moved to incubating November 21, 2023.

  • Redis Pub/sub (Redis) • Link checked 2026-11-03

    At-most-once delivery; messages missed by a disconnected subscriber are lost.

  • sync package (Map) (The Go Authors) • Link checked 2026-11-03

    The access patterns sync.Map is optimized for.

Revision history

  • — Added a reproducible staleness lab. Removed unmeasured latency figures (400 ns, 0.8 ms, sub-millisecond targets, 98% hit rates) in favor of a measured benchmark, replaced the Redis seat counter with database-enforced limits, and noted that Redis Pub/Sub is at-most-once. Corrected the OpenFeature maturity label and source names.
  • — Added version checks and bounded TTL to the invalidation design.