Kulcare Docs

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:

TableHolds
system.feature_flagsThe catalog: one row per flag key, with its metadata and default_enabled
system.feature_flag_overridesDecisions: 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_enabled

Two 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"] returns false for 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)  # scoped

    BILLING_CASHFREE_ENABLED is Tenant-scoped but checked with no actor in Billing::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 entity

Models 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 /identity but never through /system/admin.
  • entity_types must be a subset of Tenant, User, Practitioner. Facility is valid in the model but has no admin page, so the endpoint refuses it.
  • An empty entity_types is 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_enabled is 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_reference

Use 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:

PageManagesTypical flags
Facilities → facility → Feature Flagsthat facility's tenantSHOW_* 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 Flagsthe practitionernone 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_enabled

enable / 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/:key

Their 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.all alongside 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.

FlagEnforced byEffectDefault
SHOW_CMS_APPCMSLayouthides the CMS in the UION
SHOW_GROWTH_APPGrowthLayouthides the Growth app in the UION
SHOW_REPORTS_APPReportsLayouthides the Reports app in the UION
SHOW_HOSPITAL_APPAppRoutes/hsp/* is not mountedOFF
SHOW_RESEARCH_APPAppRoutes and Research::BaseController/research/* is not mounted, and the API 404sOFF

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.

FlagConcernControllersChecked againstDefault
ENABLE_PACKAGESIdentity::PackagesGatedservice_packages, patient_package_subscriptions, vendor_packagescurrent_tenant, falling back to the vendor's tenantON
ENABLE_AI_ASSISTANTClinical::AiGatedai_suggestions, transcription_sessionscurrent_tenantOFF
IPD_LITEClinical::IpdGatedadmissions, discharge_summaries, facility_rooms, facility_wards, facility_bedscurrent_tenantOFF
IPD_DIRECT_ADMISSIONClinical::IpdGateddirect and emergency admission, on top of IPD_LITEcurrent_tenantOFF

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.ts is an orphaned mock with no remaining importers. The real path is FeatureFlagContext plus the @kulcare/shared-services identity hooks, which write the @kulcare/auth-identity store that useFeatureFlags() reads.
  • Frontend gates must keep hooks unconditional. Flags arrive asynchronously, so a flag flips false -> true after mount; an early return above 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 pass enabled to 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:

SystemStorageRead through
Native (this doc)system.feature_flags, system.feature_flag_overridesIdentity::FeatureFlags
Legacy parts/basic_feature_flags, feature_flagsFeatureFlagService, clinic.feature_enabled?
Frontend static confighardcoded in apps/shell/src/config/featureFlags.tsimported 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.

  • backend/domains/identity/services/feature_flags.rb — the facade
  • backend/domains/system/models/feature_flag.rb — catalog, validations
  • backend/domains/identity/concerns/feature_flaggable.rb — has_feature?
  • backend/domains/clinical/concerns/ipd_gated.rb — the gate pattern to copy
  • backend/db/seeds/reference/016_seed_feature_flags.rb — the catalog
  • backend/domains/system/test/feature_flag_catalog_test.rb — guards the shipped defaults and entity scopes
  • backend/lib/data/basic_feature_flags.json — the legacy catalog, not this one
  • docs/plans/2026-05-14-refactor-replace-flipper-with-native-feature-flags-plan.md — the migration plan and its phases

On this page