Feature Flags
The native feature-flag system (#1409). Replaced the Flipper gem entirely — there
is no Flipper constant in the codebase, and any snippet calling
Flipper.enable / Flipper.disable / Flipper.enabled? will raise NameError.
The model
Two tables, both in the system schema and both global — no tenant_id, no
RLS:
| Table | Holds |
|---|---|
system.feature_flags | The catalog: one row per flag key, with its metadata and default_enabled |
system.feature_flag_overrides | Decisions: one row per (flag, entity), plus at most one global row per flag |
An override row with entity_type and entity_id set applies to that one
entity. A row with both NULL is the flag's single global override — the
replacement for an entity-less Flipper.enable(:KEY). Uniqueness is enforced by
partial indexes (idx_ffo_entity_unique, idx_ffo_global_unique), not by
model validations.
Resolution order
entity override > global override (NULL entity) > flag.default_enabledTwo rules that are easy to get wrong:
-
An unknown flag reads as
false.enabled?does not raise. Reference a flag in code before seeding it and the feature is silently off everywhere. For a default-OFF flag that is forgiving; for a default-ON flag, ship the catalog entry before the code that reads it. -
A scoped flag is off for entity types it does not apply to. A flag with
entity_types: ["Tenant"]returnsfalsefor a User, whatever its default. This keeps scope meaning the same thing on read as it does on the entity pages.Entity-less checks deliberately bypass that filter. They are asking for the global value:
Identity::FeatureFlags.enabled?(:BILLING_CASHFREE_ENABLED) # global value Identity::FeatureFlags.enabled?(:BILLING_CASHFREE_ENABLED, tenant) # scopedBILLING_CASHFREE_ENABLEDis Tenant-scoped but checked with no actor inBilling::Gateways::GatewayConfig, which is why the carve-out exists.
Reading a flag
Everything goes through the facade, Identity::FeatureFlags. Do not touch
System::FeatureFlag or System::FeatureFlagOverride directly.
Identity::FeatureFlags.enabled?('ENABLE_SURGERY', tenant) # => true / false
Identity::FeatureFlags.enabled?(:BILLING_CASHFREE_ENABLED) # entity-less
Identity::FeatureFlags.list_for(user) # every flag for that entityModels that include Identity::Concerns::FeatureFlaggable (User, Tenant,
Practitioner, Facility) also get has_feature?, which additionally raises
if the flag is not intended for that entity type:
tenant.has_feature?('IPD_LITE') # => true / false
user.has_feature?('IPD_LITE') # raises ArgumentError (Tenant-scoped flag)enabled? is at most two indexed lookups; list_for is one catalog query plus
one overrides query, with no N+1.
Creating a flag
Two routes in, depending on whether the flag should exist everywhere or only in one environment.
From the admin app (per environment)
Platform admin → Platform → Feature Flags → New feature flag.
Key, optional category, optional description, which entity types it applies to,
and whether it defaults on. The key is stored upcased. A new flag has no
overrides, so every entity sees default_enabled until someone sets one —
creating a flag never switches anything on by itself.
The equivalent API call:
POST /system/admin/feature_flags
{
"key": "ENABLE_TELERADIOLOGY",
"category": "clinical_features",
"description": "Enable the teleradiology reporting workflow.",
"entity_types": ["Tenant"],
"default_enabled": false
}Constraints worth knowing before you hit a 422:
- The key must be
SCREAMING_SNAKE_CASE(A-Z,0-9,_, starting with a letter). Lookups upcase, so a lowercase key would be reachable through/identitybut never through/system/admin. entity_typesmust be a subset ofTenant,User,Practitioner.Facilityis valid in the model but has no admin page, so the endpoint refuses it.- An empty
entity_typesis meaningful: it means the flag applies to any entity type. It is not the same as forgetting to pick one.
From the seeder (every environment)
The bootstrap catalog is declared in Ruby, in
backend/db/seeds/reference/016_seed_feature_flags.rb. Add an entry to
NativeFeatureFlagsSeeder::CATALOG:
{
key: 'ENABLE_TELERADIOLOGY',
entity_types: %w[Tenant],
default_enabled: false,
category: 'clinical_features',
description: 'Teleradiology reporting workflow. Gated by Clinical::TeleradiologyGated.'
}entity_types is an array, so a flag applying to two types is one entry:
%w[Tenant Practitioner].
The catalog is not read from lib/data/basic_feature_flags.json. That file
is still live, but it now belongs solely to the legacy parts/ system — see
"Three systems share these key names" below. The two stores were decoupled
because sharing one file meant a default could only change in both at once, and
they drifted whenever only one seeder ran.
Two rules for what earns an entry:
- A flag earns a row when code reads it. Every key in the catalog is enforced today. A flag nobody reads is not a toggle; it is a switch wired to nothing, and it invites the assumption that flipping it does something. Add the gate and the flag in the same change.
default_enabledis the rollout plan, because gates ship with no backfill. See "Three rules before you add a gate".
Then run the seeder. It upserts by key, so re-running is safe:
bundle exec rake new_schema:seed_referenceUse that task, not rails db:seed — db/seeds.rb hard-aborts outside
development and test, because it also loads demo identity seeds with fake PII.
How the two interact
The seeder upserts and does not delete. A flag created in the admin app
survives re-seeding. A flag in both places has its category, description,
entity_types and default_enabled reset to the seeder's values on the
next run. Overrides are not touched.
Dropping a key from CATALOG therefore does not remove it from an
environment that was seeded earlier. The row and its overrides stay until
someone prunes them by hand. Nothing reads them; they only linger in the admin
app's list.
RETIRED_KEYS is the exception — keys there are actively destroyed, with their
overrides, and the count is logged. Use it only when a flag's continued
existence is itself the problem, not merely when it is unused.
SHOW_CONNECT_APP is the current entry: Connect access is granted at
registration, so there is no segment to hide it from, and while the flag exists
it reads as a supported way to switch Connect off.
There is currently no edit or delete for the catalog in the admin app. A mistyped key has to be fixed from a console.
Turning a flag on or off
Overrides are managed from the entity you are looking at, not from the flag list. In the admin app:
| Page | Manages | Typical flags |
|---|---|---|
| Facilities → facility → Feature Flags | that facility's tenant | SHOW_* app visibility (SHOW_CMS_APP, SHOW_GROWTH_APP, SHOW_REPORTS_APP, SHOW_HOSPITAL_APP, SHOW_RESEARCH_APP), ENABLE_PACKAGES, ENABLE_AI_ASSISTANT, IPD_LITE |
| Practitioners → practitioner → Feature Flags | the practitioner | none seeded; specialist modules are entitlements (Basic Details → specialties), not flags |
App visibility flags (SHOW_*) are scoped to Tenant (#2284), so they are
managed on the facility's Feature Flags tab. The Users detail page has no
Feature Flags tab.
The admin app has no Tenants section — Facilities is the way into a tenant, so the facility tab manages tenant-wide flags and says so.
Each row shows where its value comes from: an explicit Override, Inherited (global), or Default. A reset action appears only where there is an override to clear; clearing an entity override exposes the global one underneath, not the catalog default.
From a console:
Identity::FeatureFlags.enable('ENABLE_SURGERY', tenant)
Identity::FeatureFlags.disable('ENABLE_SURGERY', tenant)
Identity::FeatureFlags.clear('ENABLE_SURGERY', tenant) # drop the override
# Global (entity-less) — the rollback lever for flags like BILLING_CASHFREE_ENABLED
Identity::FeatureFlags.disable(:BILLING_CASHFREE_ENABLED)
Identity::FeatureFlags.clear(:BILLING_CASHFREE_ENABLED) # back to default_enabledenable / disable raise ArgumentError on an unknown key.
API surface
Platform-admin only. All of /system/admin/* inherits the platform-admin gate
and writes one Audit::AdminAccessLog row per request, targeting the entity
whose configuration changed.
GET /system/admin/feature_flags catalog
POST /system/admin/feature_flags add a flag
GET /system/admin/feature_flags/:key one flag + its overrides
GET /system/admin/entities/:entity_type/:entity_id/feature_flags flags for one entity
PUT /system/admin/entities/:entity_type/:entity_id/feature_flags/:key { "enabled": true }
DELETE /system/admin/entities/:entity_type/:entity_id/feature_flags/:key clear the override:entity_type is Tenant, User or Practitioner (case-insensitive in the
path).
The older per-entity endpoints under /identity still exist:
GET /identity/{users,tenants,practitioners}/:id/feature_flags
PUT /identity/{users,tenants,practitioners}/:id/feature_flags/:keyTheir gates differ by verb, and the split is load-bearing:
- Writes are platform-admin only. Without that, any authenticated user could enable a paid module for their own tenant by putting its id in the URL (#1905).
- Reads are allowed for the entity you are — yourself, a tenant your session
is currently scoped to, or your own practitioner record. The shell fetches its
own flags on every login and profile switch, inside a
Promise.allalongside the permissions call, so a 403 there would drop the user's permissions too and land them in an empty app shell.
Enforcement
Every flag in the catalog is enforced. That is the rule the seeder keeps: a key earns a row only when code reads it. So unlike the old 25-flag catalog, you can trust that a flag listed in the admin app does something.
Two caveats. An environment seeded before a key was dropped still carries the stale row, because the seeder never deletes — so on an older database the admin app may list flags that are not in the tables below, and those do nothing. And the same key names exist in two other systems, so grep hits are not proof; read "Three systems share these key names" below.
Which layer enforces which flag
Pick the layer by what the flag means, not by which is convenient.
Frontend only — progressive disclosure. Nothing to protect, so no backend gate. A user who calls the API directly gets a feature we were shipping anyway.
| Flag | Enforced by | Effect | Default |
|---|---|---|---|
SHOW_CMS_APP | CMSLayout | hides the CMS in the UI | ON |
SHOW_GROWTH_APP | GrowthLayout | hides the Growth app in the UI | ON |
SHOW_REPORTS_APP | ReportsLayout | hides the Reports app in the UI | ON |
SHOW_HOSPITAL_APP | AppRoutes | /hsp/* is not mounted | OFF |
SHOW_RESEARCH_APP | AppRoutes and Research::BaseController | /research/* is not mounted, and the API 404s | OFF |
The two that default OFF are unreleased apps, not features anyone has lost. Research in particular is pre-release: it stays off until the U14 eval acceptance gates pass, then goes on per pilot tenant.
Research is the one flag checked in both layers, and it is worth understanding why. It is not an entitlement — entitlement will be the Research app's own subscription — but the engine is pre-release, so the backend check is a kill switch that keeps it shut until the U14 eval acceptance gates pass. It renders 404, not 403: the endpoints should not advertise themselves.
That check runs after require_authentication!. It evaluates SHOW_RESEARCH_APP
against current_tenant (or entity-less if the caller is a standalone clinician
with no tenant selected). An anonymous caller therefore gets 401 and never
reaches it — which is not a regression, since require_authentication! always
raised first and anonymous callers never saw the old ENV['SHOW_RESEARCH_APP']
gate either.
It is temporary. When the subscription lands, the check goes and the subscription becomes the gate.
Backend — entitlement and ops. A paid module, a metered feature, or a
routing switch. A UI-only gate here would be a paywall made of CSS. Each concern
supplies a private ensure_*! method that renders 403, registered as a
before_action.
| Flag | Concern | Controllers | Checked against | Default |
|---|---|---|---|---|
ENABLE_PACKAGES | Identity::PackagesGated | service_packages, patient_package_subscriptions, vendor_packages | current_tenant, falling back to the vendor's tenant | ON |
ENABLE_AI_ASSISTANT | Clinical::AiGated | ai_suggestions, transcription_sessions | current_tenant | OFF |
IPD_LITE | Clinical::IpdGated | admissions, discharge_summaries, facility_rooms, facility_wards, facility_beds | current_tenant | OFF |
IPD_DIRECT_ADMISSION | Clinical::IpdGated | direct and emergency admission, on top of IPD_LITE | current_tenant | OFF |
One carve-out. ENABLE_PACKAGES answers "has this practice bought the
packages module", so it needs a tenant to mean anything. A standalone
marketplace vendor has none — Identity::Vendor#tenant is optional — and is
not consuming a clinic's entitlement: their packages are their own product.
VendorPackagesController therefore skips the gate when no tenant resolves. A
vendor that does belong to a tenant stays subject to that tenant's flag.
require_vendor still refuses non-vendors, so skipping the gate does not open
the endpoint.
BILLING_CASHFREE_ENABLED is backend too, but not by a concern:
Billing::Gateways::GatewayConfig#gateway_for_market and Identity::Vendor
read it to route India to Cashfree instead of Razorpay. It is the documented
rollback lever, so it is checked entity-less.
Gating an entitlement flag in both layers is right and is not duplication: the backend gate is correctness, the frontend one turns a raw 403 into a readable "not available on your plan".
Prefer fewer backend gates
A frontend flag that misreads hides a button. A backend flag that misreads takes
out an endpoint for everyone, and every backend failure mode fails closed and
wide: an unseeded catalog reads every flag false, a wrong entity_types value
is a silent total outage, and an ActiveSupport callback-dedup collision leaves
actions ungated while the docs claim otherwise.
SHOW_CMS_APP used to gate the patients, appointments, prescription_groups and
medication_requests controllers — core clinical endpoints held hostage by a
nav-level concern. That gate, and the Growth equivalent, were removed. Both
concerns are deleted.
Three rules before you add a gate
1. A gate ships with no backfill, so default_enabled is the whole rollout
plan. Resolution falls through to the catalog default for every existing
account. A flag that gates a surface people already use must default ON, or
enforcement takes the feature away from everyone on deploy day. That is why
ENABLE_PACKAGES defaults on while ENABLE_AI_ASSISTANT, which nobody has
yet, defaults off.
System::FeatureFlagCatalogTest guards these defaults, and the entity scope of
each gated flag, by reading the seeded rows. The gate tests cannot do this job:
they set default_enabled themselves before asserting on it, so they stay green
against a seeder that ships the wrong default.
2. Do not put an unconditional before_action in a concern that any
controller might re-declare with only:. ActiveSupport dedups callbacks on
kind and filter and ignores only:, so the controller's declaration
silently replaces the concern's — leaving every other action ungated while
the concern's docs claim otherwise. Connect::SearchGated hit exactly this and
had to register no callback of its own, with each controller opting in per
action. That concern has since been deleted along with its flag (#2448), but
the rule stands. The remaining concerns keep the included do form because
none of their includers re-declares the filter. If you add one that does, you have just disabled the
gate for every other action.
3. Use Identity::FeatureFlags.enabled?, not has_feature?.
has_feature? raises ArgumentError on an entity-type mismatch, and
Clinical::BaseController's rescue_from ArgumentError re-raises, so a scope
mistake becomes a 500 instead of a 403. enabled? returns false.
Do not gate anything in parts/
Identity::FeatureFlags.enabled? calls entity.flipper_entity_type. In
parts/, current_user is the legacy ::User, which does not include
Identity::Concerns::FeatureFlaggable — so the gate raises NoMethodError in a
before_action, which is a 500 on every authenticated request. Several
parts/ controllers also skip_before_action :authenticate_user, so
current_user is nil and a gate would 403 the whole public path. parts/ is
being retired; put the gate in domains/ instead.
Keys the catalog no longer carries
The old catalog seeded 25 flags from the legacy JSON. Most of them gated
nothing, so they are no longer seeded: ENABLE_SURGERY, ENABLE_IPD,
ENABLE_GROWTH, ENABLE_ABDM,
ENABLE_WORKFLOWS, ENABLE_BETA_FEATURES, ENABLE_COMMISSION_TRACKING,
and ENABLE_REV_SHARING. On a database seeded earlier they still exist and
still do nothing.
Some of these names are read elsewhere, off the legacy tables — see the next section. A toggle in the admin app does not reach those readers.
SHOW_CONNECT_APP is different: it is actively deleted, via RETIRED_KEYS.
Every user gets Connect access at registration, so there is no segment to hide
it from. A RequireAccess(SHOW_CONNECT_APP) wrapper in ConnectLayout has been
added and reverted twice; each time it locked State A users — zero memberships,
therefore no flags — out of the onboarding surface that is the entire point of
Connect for them. It also denies during the window before flags load, because
loading is indistinguishable from denied.
The six specialist module flags are actively deleted too (ENABLE_DENTAL_MODULE,
ENABLE_CARDIOLOGIST_MODULE and friends), via RETIRED_KEYS, since #2505. The
practitioner's active module entitlement is the only thing that decides a
specialist workflow now (Clinical::EncounterTypeConfigs::SpecialistModuleAccess).
The seeder logs each override it deletes (entity and value), which is the record
to use if that change is ever rolled back.
That is the native catalog only. ENABLE_DENTAL_MODULE also names a still-live
legacy parts/ flag, read by the parts/patient dental exam API through
FeatureFlagService, which this retirement leaves untouched. See "Three systems
share these key names" below.
One trap in the dropped list: ENABLE_IPD is not the IPD gate —
Clinical::IpdGated reads IPD_LITE.
SHOW_HOSPITAL_APP and SHOW_REPORTS_APP were dropped in the first pass and
then restored. Dropping SHOW_REPORTS_APP was a mistake: ReportsLayout still
gated on it, and an unknown key resolves to false, so Reports would have been
unreachable for every user on a freshly seeded environment — and unrecoverable
from the admin app, because enable raises on an unknown key.
System::FrontendFlagGatesTest now guards against that in both directions.
Known gap: a frontend gate cannot tell "loading" from "denied"
This matters more now that SHOW_CMS_APP and SHOW_GROWTH_APP have no backend
gate behind them.
setFeatureFlagsLoading has zero callers, so featureFlagsLoading is
permanently false, and RequireAccess never consults it — it computes
hasAccess and renders fallback when false. Flags start as {} and populate
asynchronously. So every frontend gate renders denied while flags are in
flight, and stays denied if the fetch fails, with no retry.
Before the frontend takes on more gating, RequireAccess needs to distinguish
loading, denied and errored, and an errored fetch should fail open or retry
rather than locking the user out of an app they have paid for.
Loose ends
authorize_feature!(Shared::MultiTenantAuthenticable) has zero callers. Two competing gate mechanisms would be worse than one, so either wire it up or delete it — do not add a third.frontend/apps/shell/src/services/featureFlagsApi.tsis an orphaned mock with no remaining importers. The real path isFeatureFlagContextplus the@kulcare/shared-servicesidentity hooks, which write the@kulcare/auth-identitystore thatuseFeatureFlags()reads.- Frontend gates must keep hooks unconditional. Flags arrive asynchronously, so
a flag flips
false -> trueafter mount; an earlyreturnabove the hooks makes React raise "Rendered more hooks than during the previous render" — a crash, and one that hits only users who have the feature. Put the check in a wrapper component, or passenabledto the query.
Three systems share these key names
Grepping a flag key finds hits that have nothing to do with this store. There are three independent implementations, and only the first is the one this document describes:
| System | Storage | Read through |
|---|---|---|
| Native (this doc) | system.feature_flags, system.feature_flag_overrides | Identity::FeatureFlags |
Legacy parts/ | basic_feature_flags, feature_flags | FeatureFlagService, clinic.feature_enabled? |
| Frontend static config | hardcoded in apps/shell/src/config/featureFlags.ts | imported directly |
So ENABLE_IPD and ENABLE_SURGERY are read — by
parts/core/models/user_account.rb and
parts/core/interactors/user_methods/clinics.rb, off the legacy tables.
Toggling them in the admin app changes the native store and has no effect on
those reads. The same is true of ENABLE_PACKAGES, which the legacy
packages_enabled: serializer field resolves through FeatureFlagService
while the new domains/identity endpoints resolve it natively.
The two backends have separate sources and can disagree. The native catalog
is declared in db/seeds/reference/016_seed_feature_flags.rb. The legacy tables
are seeded from lib/data/basic_feature_flags.json by
db/seeds/_legacy/seed_basic_feature_flags.rb and lib/tasks/feature_flags.rake.
They used to share that JSON, which was worse: a default could only be changed in both at once, and they drifted anyway whenever only one seeder ran. Splitting them makes the divergence explicit rather than accidental. A flag that must behave the same on both sides has to be changed in both places, deliberately.
Do not "just re-run" the legacy seeder to sync them.
db/seeds/_legacy/seed_basic_feature_flags.rb starts with FeatureFlag.delete_all
and BasicFeatureFlag.delete_all, which destroys every per-entity override.
Related
backend/domains/identity/services/feature_flags.rb— the facadebackend/domains/system/models/feature_flag.rb— catalog, validationsbackend/domains/identity/concerns/feature_flaggable.rb—has_feature?backend/domains/clinical/concerns/ipd_gated.rb— the gate pattern to copybackend/db/seeds/reference/016_seed_feature_flags.rb— the catalogbackend/domains/system/test/feature_flag_catalog_test.rb— guards the shipped defaults and entity scopesbackend/lib/data/basic_feature_flags.json— the legacy catalog, not this onedocs/plans/2026-05-14-refactor-replace-flipper-with-native-feature-flags-plan.md— the migration plan and its phases