Feature Entitlements, Bundles & Grey-Out
Canonical reference for Oktivo's feature-gating model: what a customer is entitled to, how features depend on each other, how bundles and role presets package them, and how un-entitled sections are hidden or greyed out.
This is the "single map" that ties together three systems that are easy to confuse. Read TEAM-RBAC.md for the per-team-member permission layer and CUSTOMER-ONBOARDING.md for how a new account is seeded.
1. The three layers (keep these separate)
A capability is reachable only when ALL layers that apply to it pass:
- ENTITLEMENT — does the account have the feature at all? Resolved as
org.plan.features ∩ user.features. A customer effectively holds a feature only when BOTH the org plan and the user's feature set include it. Team members have nofeaturesof their own — they inherit the account owner's set (effectiveFeatures). Source:useFeature.ts(frontend),feature.middleware.ts(backend). - RBAC — what may a team member do inside a held feature? Per-resource
view/create/edit/delete+ capabilities + sub-section + widget grants, resolved from assigned roles (+ optional per-user override). Owners / admins / superadmins bypass RBAC. Source:permissions.types.ts,rbac.middleware.ts. SeeTEAM-RBAC.md. - WIDGET DATA — is a dashboard/marketing/segment widget's data returned? Widget grants gate individual cards. A subset is DATA-enforced server-side (
API_ENFORCED_WIDGETS): the endpoint omits that widget's slice for a team member who lacks the grant, not just hides it client-side.
Entitlement decides whether a module exists for the account; RBAC decides what a team member does with it. They are orthogonal axes.
2. Source of truth (files)
| Concern | Frontend | Backend |
|---|---|---|
| Feature registry | app/src/lib/features.ts (FEATURES) | types/index.ts (AVAILABLE_FEATURES) |
| Picker dependencies (silent auto-add/remove) | FEATURE_DEPENDENCIES + toggleFeatureWithDependencies | — |
| Module dependencies (block-on-save) | MODULE_DEPENDENCIES + validateModuleSelection | MODULE_DEPENDENCIES + validateModuleDependencies (mirror, drift-guarded) |
| Module groups (admin grids + signup) | SIDEBAR_FEATURE_GROUPS | — |
| Sub-sections | MODULE_SUBSECTIONS + effectiveSubSectionKeys | mirror |
| Bundles | app/src/lib/bundles.ts | — (frontend-only presets) |
| Role presets | app/src/types/permissions.ts | repositories/role.repository.ts (SYSTEM_ROLE_TEMPLATES) |
| Widget data enforcement | API_ENFORCED_WIDGETS | API_ENFORCED_WIDGETS (mirror, drift-guarded) |
| Grey-out policy | SidebarLayout.tsx | Organization.uiConfig.lockedModuleDisplay + SystemConfig global |
The frontend/backend mirrors (MODULE_DEPENDENCIES, API_ENFORCED_WIDGETS, MODULE_SUBSECTIONS, connecter providers) are kept in lockstep by drift-guard tests.
3. Two dependency systems (they behave oppositely)
FEATURE_DEPENDENCIES— PICKER semantics (silent). Ticking a dependent auto-adds its prerequisite; unticking a prerequisite auto-removes its dependents. e.g.call_analysis → call_transcription,crm_segments → crm_leads,gmail → crm_leads,tickets.* → tickets,whatsapp.* → whatsapp,monitor.activity.orders/payments → commerce.MODULE_DEPENDENCIES— BLOCK-ON-SAVE semantics (explicit 400 + warning; never silently mutates the selection). Current rules:tickets → crm_leadsworkflow → voice_campaignwhatsapp.automation → voice_campaigncall_flow_builder → voice_campaignmonitor.activity.orders → commerce,monitor.activity.payments → commerce
Note:
crm_leads → voice_campaignwas REMOVED (Phase 1) so CRM is sellable without Call.MODULE_DEPENDENCIESis write-time only; existing stored docs that violate a rule keep working until the next save.
"If a customer asks for X, turn on …" (dependencies resolved)
- Call only:
voice_campaign,web_call,phone_to_phone_call,ai_call,web_agent,recorded_message,inbound_calls,csv_upload,call_flow_builder,benchmark. Add-ons (billed per use):call_recording,call_transcription,call_analysis. - CRM only:
crm_leads+ CRM sub-sections (crm.contacts/companies/opportunities/ tasks),crm_segments,crm.marketing,crm.agentic,lead_analytics. Novoice_campaignrequired. - Commerce only:
commerce+commerce.stores/inventory/orders/transactions(+ monitor order/payment tabs, each requiringcommerce). - CRM + Tickets: CRM set above plus
tickets(+ ticket sub-sections).ticketsstill requirescrm_leads. - Call + WhatsApp/Marketing:
voice_campaign+whatsapp(+ channels); WhatsApp automation additionally requiresvoice_campaign; the Marketing dashboard ridescrm.marketing→crm_leads.
4. The three native call entitlements (Phase 1)
The Lead Detail → Call tab's three modes are separate paid features, gated both client-side (LeadCallPanel via useFeature) and server-side:
| Mode | Endpoint | Feature key |
|---|---|---|
| Web call | POST /api/leads/:leadId/calls/web | web_call |
| Phone-to-phone | POST /api/leads/:leadId/calls/phone-to-phone | phone_to_phone_call |
| AI agent | POST /api/leads/:leadId/calls/agent | ai_call |
Each route layers requireFeatureAccess(<key>) above crm.view. A CRM-only customer with no call feature can work leads but cannot place calls.
Back-compat: holding the legacy voice_campaign feature implies ai_call at entitlement-resolution time (expandImpliedFeatures in feature.middleware.ts and useFeature.hasFeature), so existing customers never lose AI calling on deploy. No DB migration.
5. Bundles (Phase 2)
A bundle is a named, dependency-resolved quick-start feature set (app/src/lib/bundles.ts). Applying one sets the admin grid / signup selection via the grid's bulk-change; the block-on-save validateModuleSelection still governs the write, so a bundle can never bypass validation.
| Bundle | Intent | Notes |
|---|---|---|
call_only | Calling platform | All three call modes + campaigns + IVR. Recording/transcription/analysis are add-ons, NOT bundled. |
crm | CRM suite | Excludes voice_campaign — sellable without Call. |
commerce | Ecommerce | Stores/inventory/orders/transactions + activity-monitor tabs. |
whatsapp_marketing | WhatsApp + ads | Full WhatsApp stack + chatbot + ads + Marketing dashboard (pulls in voice_campaign + crm_leads via real dependencies). |
full_suite | Everything grantable | Computed (every non-legacy, non-role-managed key minus the pure-rate call_transcription_stt), so it never drifts as FEATURES grows. |
Helpers: resolveBundle(id), applyBundleToSelection(current, id), isBundleActive(current, id) (highlight an applied bundle), removeBundleFromSelection(current, id) (toggle off, preserving overlap with other still-active bundles). Covered by app/src/lib/bundles.test.ts, including an orphan guard asserting every grantable feature belongs to a SIDEBAR_FEATURE_GROUPS group (so the admin grid's "Other Platform Features" bucket stays empty).
6. Role presets (Phase 2)
Four system roles are seeded per account owner — see TEAM-RBAC.md for the full matrix: Org Admin (full), Manager (CRUD-minus-delete + sensitive caps + all widgets), Agent (view/create, scope="assigned", operational widgets only), Viewer (read-only, overview widget only). Seeded idempotently on owner creation; editing a preset mutates only that owner's copy.
7. Grey-out vs hide (Phase 3, org-configurable)
For features a customer is NOT entitled to, the sidebar can either HIDE the item (default, unchanged for existing orgs) or GREY IT OUT as a locked row with an upgrade/contact message. Policy resolution: **org override > superadmin global default
hide**.
- Superadmin global default:
SystemConfig(__default__), set insuperadmin/SystemConfigPage.tsx. - Per-org override:
Organization.uiConfig.lockedModuleDisplay: 'hide' | 'grey_out'(defaultnull= inherit global), set in the org management UI. - Resolver:
backend/.../utils/uiConfig.utils.ts(pure, tested). The resolved policy is threaded to the client on login /getMeandSidebarLayouthonours it by reusing the EXISTING locked-upsell renderer (no second renderer).
8. "Common to all" (never gated)
Dashboard, Account/Settings (Profile, KYC, Security, Billing, API Tokens), Credits, and Notifications have no feature gate — every customer gets them regardless of plan. Keep them ungated when adding new gating.
9. Known gaps / recorded (not yet enforced)
From the Phase 2 Item-C audit (.agents/tasks/platform-feature-map/phase2-itemC-audit.md), recorded for a product decision rather than silently changed:
POST /api/leads/push-to-meta,/send-to-meta— gated onfacebook_metaas of Phase 3;/send-to-google-adsgated ongoogle_ads. (These were the recorded holes; Phase 3 closed them with an authoritative gate — verify ads-feature grants before deploy, as it is behaviour-changing for a manager who had the data permission but not the ads feature.)analysisSchema.routes.ts— no module feature gate (cross-feature primitive; no single module key). Structural; still RBAC-gated onanalysis.*.- WhatsApp Automation mutations share one
whatsapp_automation.viewgrant (no create/edit split). Connecter writes gated byconnecter.view(not a write-specific grant).
10. Related docs
TEAM-RBAC.md— the per-team-member permission layer and the 4 role presets.CUSTOMER-ONBOARDING.md— how a new account's features are seeded (signup config + bundles).CRM-CALL-INTEGRATION.md/CRM-CALLING-ARCHITECTURE.md— the call systems the three call entitlements gate.ADDON-FEATURES-BILLING.md— how recording/transcription/analysis add-ons bill.