Appearance
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 entity — listings (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_codeUNIQUE,route,category,parent_feature_idhierarchy,requires_premium,is_universal).subscription_tiers.features/.limits/.advanced_features(jsonb per-tier maps) +user_subscriptions.usage/.limit_overrides(jsonb) +subscription_usage_logs(allowedbool audit).domain_features— afeature × business_domainjunction carrying its owntier_requirement(free/starter/ pro/business), plus per-useruser_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) inmanifest.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 nouseEntitlements/featureGate/hasFeaturelayer anywhere. Navigation is a staticUSER_NAVIGATIONconstant. - The only functional gate is
useMenuManagerEligibility, which hardcodesbusiness_domain_id === 1and ignores subscription tier entirely (and ignores thedomain_featurestable that was built for exactly this).
Two hard defects (not just design gaps)
- The three entitlement RPCs are broken.
get_user_subscription,user_has_feature_access, anduser_has_exceeded_limitallFROM subscriptions s … WHERE s.status = 'active'— but there is nosubscriptionstable (it'suser_subscriptions) and nostatuscolumn (it'sis_active).user_has_exceeded_limitalso selects jsonb intoINTand indexes an int with->>. They fail at runtime; nothing calls them anyway. These are the intended schema→app bridge, and they don't work. - The tier enum diverges across tables:
profiles.subscription_tierallowsfree/starter/pro/business/essential;user_subscriptions.tierallowsfree/starter/pro/business;user_feature_permissions.override_tieraddsunlimited. 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
entkeys, 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 (limitson tier,limit_overrideson 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_featuresrows (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_premiumis 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.
D — Normalized (domain × tier × feature) entitlement matrix + admin command center · [recommended, 2026-07-20]
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_featuresalready 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:
- 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). - Resolution order:
user_feature_permissions(per-user) ▸ matrix cell(domain,tier,feature)▸ tier default ▸ global default. Surfaced by oneget_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.) - One
useEntitlementsresolver in the shared TS core:hasFeature(code),limitFor(code),remaining(code),isAtLimit(code). Nav,ProtectedRoute, screens, and the template library consume it; retire the staticUSER_NAVIGATIONconstant and thebusiness_domain_id===1gate. Server re-enforces in EFs/RLS and records decisions insubscription_usage_logs. - 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. - Reconcile the tier enum to one canonical ladder across
profiles/user_subscriptions/user_feature_permissions(resolveessential/starter; decide ifunlimitedis a tier or override-only). Expand-contract migration. domain_featuresbecomes the matrix's (domain,feature) applicability layer and gains the per-tier limit dimension (or is joined to a_tier_entitlementschild) — it is promoted, not demoted as Option A said.- Design implication (shift-left): the admin
Subscriptions.dc.htmlgrows a real command center — a feature × tier grid per domain, editing matrix cells (enabled + limit + quota) against the realfeature_coderegistry, 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 speakfeature_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-moduleplan: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-allops 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.usagevslimits/limit_overrides, audited insubscription_usage_logs.
Open questions for product
- Add-ons / feature packs: how do they layer onto
subscription_tiers.features— a merged overlay on the user's subscription, or separateuser_feature_permissionsgrants? (Recommend the latter: an add-on purchase writes auser_feature_permissionsgrant, keeping the plan map clean.) essentialtier — real and shipping, or stale enum residue to drop?- Limit-exceeded UX — hard block vs soft (allow + upsell)? Affects whether
subscription_usage_logs.allowedgates or just records.
Related
- 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).