Skip to content

ADR-0007 · Feature entitlements & subscription gating

Status: 🟡 Proposed · revised 2026-07-20 (domain-scoped matrix) · Depends on: ADR-0002 · Related: ADR-0003, ADR-0006, ADR-0008, ADR-0009

Depends on the archetype model (ADR-0009, 2026-07-20)

Entitlement limits key off the archetype's primary entitylistings (Inventory), appointments_per_month (Booking), portfolio_photos/leads_per_month (Lead-Portfolio), products/orders_per_month (E-commerce), offerings (Catalog). ADR-0009 supplies what entities exist per vertical; this ADR supplies how many each tier gets. The admin command center is shared: it hosts both the vertical registry (ADR-0009) and the entitlement matrix (this ADR). The confirmed R1 archetypes include E-commerce/cart as a core archetype.

Revised 2026-07-20 — the recommendation changed

Product clarified that entitlements must be business-domain-scoped, not just tier-scoped: the same feature carries different limits per domain per tier (a real-estate agent's free plan = 3 listings / 5 photos → Pro = 10 → Business = unlimited; an electrician has an entirely different feature+limit set). The original recommendation (Option A, jsonb-on-tier, with domain_features demoted to applicability-only) cannot express a per-(domain,tier) limit cleanly and is therefore superseded. The revised recommendation below is Option D — a normalized (domain × tier × feature) → {enabled, limit, quota} matrix driven by an admin command center. The original options are kept for the record.

Context

QRSETU is a freemium SaaS from day one: every user-facing feature must be enable/disable-able per subscription tier, configured as data through the admin panel's Subscription Plans — not hardcoded, not requiring a deploy to change what a plan includes. A 2026-07-20 audit across the design prototype, the real schema, and src/ found the capability exists as three disconnected, partly-broken skeletons that use three different vocabularies:

Plane 1 — Admin Subscriptions design (prototype Subscriptions.dc.html). Genuinely capable: each plan carries a typed entitlement map ent:{cards:'1', qr:'Unlimited', domain:false, analytics:'Basic', bookings:true, …} (values typed bool/text/number/"Unlimited"), a per-plan Entitlements editor sub-tab, plan inheritance (base:'business', "updates roll down to children"), Add-ons / feature packs sold on top of any plan, and Custom Deals per-tenant overrides. Good control-plane design.

Plane 2 — Real DB schema (baseline 20260710134136_…). Over-designed — it can express "feature X in tier Y with limit Z" three overlapping ways:

  • platform_features — a normalized feature registry (feature_code UNIQUE, route, category, parent_feature_id hierarchy, requires_premium, is_universal).
  • subscription_tiers.features / .limits / .advanced_features (jsonb per-tier maps) + user_subscriptions.usage / .limit_overrides (jsonb) + subscription_usage_logs (allowed bool audit).
  • domain_features — a feature × business_domain junction carrying its own tier_requirement (free/starter/ pro/business), plus per-user user_feature_permissions (grant/deny + override_tier + expiry).

Plane 3 — Runtime enforcement (console manifest.js + src/). Essentially absent, and where present, hardcoded:

  • The console hardcodes plan:'pro' per module id (loyalty, ai, team) in manifest.js — a fourth vocabulary, disconnected from planes 1 and 2.
  • In src/, subscription tier is read only for a cosmetic badge + an upgrade CTA. There is no useEntitlements/featureGate/hasFeature layer anywhere. Navigation is a static USER_NAVIGATION constant.
  • The only functional gate is useMenuManagerEligibility, which hardcodes business_domain_id === 1 and ignores subscription tier entirely (and ignores the domain_features table that was built for exactly this).

Two hard defects (not just design gaps)

  1. The three entitlement RPCs are broken. get_user_subscription, user_has_feature_access, and user_has_exceeded_limit all FROM subscriptions s … WHERE s.status = 'active' — but there is no subscriptions table (it's user_subscriptions) and no status column (it's is_active). user_has_exceeded_limit also selects jsonb into INT and indexes an int with ->>. They fail at runtime; nothing calls them anyway. These are the intended schema→app bridge, and they don't work.
  2. The tier enum diverges across tables: profiles.subscription_tier allows free/starter/pro/business/essential; user_subscriptions.tier allows free/starter/pro/business; user_feature_permissions.override_tier adds unlimited. Three different canonical sets for the same concept.

Decision drivers

  • Freemium is a day-one product mandate — plan→feature mapping must be admin-editable data, changeable without a deploy, and must gate every user-facing feature, so it has to be designed into screens now (shift-left), not retrofitted.
  • Don't rebuild — reconcile. The schema is present and, if anything, over-built. The task is to pick ONE model and wire it, not to add a fourth.
  • One vocabulary. Four feature naming systems (admin ent keys, console module ids, platform_features.feature_code, domain_features) guarantee drift. There must be a single canonical feature identifier.
  • Compose with RBAC (ADR-0006), don't merge it. Entitlements answer "does your plan include this?"; capabilities answer "does your role permit this?". Effective access = capability ∧ entitlement ∧ domain-applicability. Keep the axes separate (entitlement = subscription_tiers/billing; capability = profiles.permissions/role).
  • Portable core. The resolver belongs in the shared TS core so web and the future Expo app share one source of truth.
  • [Added 2026-07-20] Domain-scoped, three-dimensional entitlements. The unit of configuration is (business_domain × tier × feature) → {enabled, limit, quota}. Each business category has its own feature set, its own numeric limits, and its own upgrade triggers. Adding a new domain or a new plan must be data entry in an admin command center, never a code change — the model has to make that a row insert, not a migration.

Options considered

A — subscription_tiers jsonb is the tier authority, keyed by the platform_features registry

platform_features.feature_code is the single canonical feature identifier. subscription_tiers.features/limits (jsonb) map feature_code → included/limit per plan (this is what the admin ent editor writes to). user_feature_permissions provides per-user overrides. domain_features governs domain applicability only (which features even exist for a café vs a salon) — its tier_requirement column is deprecated (tier lives on the plan, not the domain). One get_my_entitlements RPC resolves tier defaults ⊕ per-user overrides, checked against domain applicability; one useEntitlements hook in the shared core; nav/routes/screens/templates consume it.

  • For: matches the admin design (per-plan typed map) exactly; plans change often for marketing — editing a plan's jsonb beats migrating a junction table; add-ons and custom-deal overrides are natural jsonb merges; minimal new schema (mostly fix + wire what exists).
  • Against: jsonb is schemaless — needs app-level validation that keys are real feature_codes; two limit stores (limits on tier, limit_overrides on user) must merge deterministically.

B — Normalized: domain_features.tier_requirement (+ platform_features.requires_premium) is the authority

Tier gating lives in the normalized junction; subscription_tiers holds price/name only.

  • For: relational integrity; queryable; no schemaless blob.
  • Against: duplicates the tier dimension across domain_features rows (feature×domain×tier) — a plan change touches many rows; fights the admin design (which is plan-centric, not domain-centric); add-ons/custom-deals don't fit a static junction; requires_premium is only a coarse boolean, not tier-granular.

C — Status quo (hardcoded per feature)

Leave gating hardcoded (plan:'pro' in code, business_domain_id===1).

  • For: zero work now.
  • Against: directly contradicts the freemium mandate — every plan change is a code deploy; no admin control; guarantees the design/runtime divergence keeps growing. Rejected.

platform_features.feature_code stays the canonical feature identifier. A normalized entitlement matrix (extend domain_features, or a new domain_tier_entitlements) stores one row per (domain_id, feature_id, tier) carrying { is_enabled, limit_value (NULL = unlimited), quota_period, metadata }. subscription_tiers keeps price/name/order and a small set of domain-independent global flags in jsonb; anything that varies by domain lives in the matrix. user_feature_permissions = per-user overrides; user_subscriptions.usage = counters enforced against the resolved limit. An admin command center does CRUD over four registries — features, domains, tiers, and the matrix cells — so a new domain/plan is row inserts.

  • For: directly models the stated requirement (real-estate free = 3 listings/5 photos → Pro 10 → Business unlimited; electrician a different row set); relational + queryable + auditable; adding a domain/tier/feature is data entry; domain_features already exists as the (domain,feature) junction to extend; limits are first-class columns, not stringly-typed jsonb.
  • Against: more rows than jsonb (|domains| × |tiers| × |features|) — mitigate with sensible defaults + inheritance (a matrix cell falls back to a tier/global default when unset, so only overrides need rows); the admin command center is a real build (but it's exactly what product asked for).

Recommendation

D (supersedes the original A). The configuration unit is the (domain × tier × feature) matrix cell, edited in an admin command center; code never encodes a limit. Concretely:

  1. Canonical registries (data, admin-managed): platform_features (feature_code) · business_domains · subscription_tiers · the entitlement matrix (domain,feature,tier) → {enabled, limit, quota}. Adding a domain or plan = inserts, not migrations. Use defaults + fallback so only non-default cells need a row (global/tier default → domain override → per-user override).
  2. Resolution order: user_feature_permissions (per-user) ▸ matrix cell (domain,tier,feature) ▸ tier default ▸ global default. Surfaced by one get_my_entitlements(user) RPC returning { tier, features:{code→{enabled,limit}}, usage }, resolved for the caller's domain. (Fix + collapse the three broken RPCs into this — tracker item.)
  3. One useEntitlements resolver in the shared TS core: hasFeature(code), limitFor(code), remaining(code), isAtLimit(code). Nav, ProtectedRoute, screens, and the template library consume it; retire the static USER_NAVIGATION constant and the business_domain_id===1 gate. Server re-enforces in EFs/RLS and records decisions in subscription_usage_logs.
  4. Compose with ADR-0006: a feature is available iff capability(role) ∧ entitlement(domain,plan) ∧ applicable(domain). Entitlement ← matrix; capability ← profiles.permissions. Two resolvers, one gate.
  5. Reconcile the tier enum to one canonical ladder across profiles / user_subscriptions / user_feature_permissions (resolve essential/starter; decide if unlimited is a tier or override-only). Expand-contract migration.
  6. domain_features becomes the matrix's (domain,feature) applicability layer and gains the per-tier limit dimension (or is joined to a _tier_entitlements child) — it is promoted, not demoted as Option A said.
  7. Design implication (shift-left): the admin Subscriptions.dc.html grows a real command center — a feature × tier grid per domain, editing matrix cells (enabled + limit + quota) against the real feature_code registry, plus domain/feature/tier management. The console/template gates key off the same codes. This feeds a re-brief once accepted (see Subscriptions review).

Consequences

  • Freemium becomes real and domain-aware: an admin sets real-estate/free = 3 listings/5 photos, Pro = 10, Business = unlimited, and an entirely different row set for electricians — all as data, no deploy.
  • A new business domain or plan ships as command-center data entry (rows), not a migration or code change — the stated flexibility goal.
  • One feature registry (platform_features) removes the four-vocabulary drift; the admin, console, templates, and DB all speak feature_code.
  • Matrix size (|domains|×|tiers|×|features|) is bounded by defaults + fallback — only cells that differ from the tier/global default are stored, so the common case is sparse.
  • manifest.js's per-module plan: flags become derived from entitlements, not the source of truth — the console nav goes data-driven (this also resolves the earlier "nav hardcoded" tracker item).
  • Templates (ADR-0003) gate by a feature_code/min-tier from the registry, not a hardcoded library min-plan.
  • The templates:demo-all ops capability (ADR-0006 D2) must bypass the entitlement gate for preview — confirming the two axes compose (a capability can override an entitlement for internal demo).
  • Usage-limited features (QR count, cards) enforce via user_subscriptions.usage vs limits/limit_overrides, audited in subscription_usage_logs.

Open questions for product

  1. Add-ons / feature packs: how do they layer onto subscription_tiers.features — a merged overlay on the user's subscription, or separate user_feature_permissions grants? (Recommend the latter: an add-on purchase writes a user_feature_permissions grant, keeping the plan map clean.)
  2. essential tier — real and shipping, or stale enum residue to drop?
  3. Limit-exceeded UX — hard block vs soft (allow + upsell)? Affects whether subscription_usage_logs.allowed gates or just records.
  • ADR-0002 — billing is the system of record for what tier a user is on; this ADR governs what a tier unlocks.
  • ADR-0006 — capabilities (role) compose with entitlements (plan).
  • ADR-0003 — templates gate via the feature registry.
  • Tracker: broken entitlement RPCs; tier-enum divergence; disconnected feature vocabularies; absent src/ enforcement (documentation/portal/dev-tracker/tracker.md).
  • Subscriptions review · manifest.js (console gating) · platform_features/subscription_tiers/domain_features/user_feature_permissions (baseline schema).