# Feature Flags Last updated: 2026-05-19 ## Purpose This document describes the in-house feature flag system that replaces the old Split/Harness runtime dependency for the bodyshop application. The goal of this system is to be a practical drop-in replacement for the existing Split usage in the front end while letting us manage feature flag definitions and bodyshop assignments from our own admin panel. It uses: - Hasura/Postgres for durable flag definitions and per-bodyshop assignments. - The bodyshop backend for authenticated runtime flag evaluation. - Redis for short-lived runtime response caching. - Hasura event triggers plus admin-side invalidation for cache freshness. - The admin panel for creating, editing, deleting, and assigning flags. - A local compatibility module in the bodyshop client so existing Split-shaped hooks/components keep their API shape. The important design choice is that the bodyshop application does not talk to Harness/Split at runtime anymore. It asks our backend for feature flags for the active bodyshop, and the backend reads our database. ## Current Status The system currently supports: - Global feature flag definitions. - Per-bodyshop flag assignments. - Optional `activeDate` and `deactiveDate` scheduling on per-bodyshop assignments. - Custom treatment values, not just `on`, `off`, and `control`. - Optional JSON config per assignment. - Runtime Redis caching. - Targeted cache invalidation when one bodyshop's flag assignments change. - Global cache invalidation when feature flag definitions change. - Live browser refresh through the existing Socket.IO connection when flags change. - Admin CRUD for flag definitions. - Admin assignment management from both the bodyshop edit screen and the flag CRUD screen. - Importing exported Harness/Split assignments by matching Harness target keys to `bodyshops.imexshopid`. - A temporary footer indicator that displays `Test Feature Flag Enabled` when `TEST_FLAG` is `on`. ## Glossary ### Feature Flag Definition A global registry row in `public.feature_flags`. This answers: - What is the flag called? - Is this flag globally active? - What is the default treatment when a bodyshop has no explicit assignment? - What description should admins see? Example: ```text name: Enhanced_Payroll default_treatment: off active: true description: Enable enhanced payroll and labor allocation features. ``` ### Bodyshop Assignment A per-shop override row in `public.bodyshop_feature_flags`. This answers: - Which bodyshop has this flag assigned? - What treatment does that bodyshop receive? - Is there config attached to this flag? - Is the assignment only active during a date window? Example: ```text bodyshopid: 2f9... name: Enhanced_Payroll treatment: on config: null activeDate: null deactiveDate: null ``` ### Treatment The value returned for a flag. Historically this was commonly `on`, `off`, or `control`, but Split supports arbitrary treatments, so our database now allows any non-empty text. Examples: ```text on off control variant-a new-workflow demo ``` The client compatibility layer treats `off` and `control` as disabled in boolean-style code paths. Code that asks for the treatment string can receive any treatment value. ### Config Optional JSON attached to a bodyshop assignment. Example: ```json { "buttonText": "Try new workflow", "limit": 25 } ``` This is returned through `useTreatmentWithConfig` and `useTreatmentsWithConfig`. ### Active Date and Deactive Date Optional schedule fields on an assignment: - `activeDate`: the assignment should not turn on before this timestamp. - `deactiveDate`: the assignment should stop being active at or after this timestamp. Both fields are optional. If a flag is assigned with no dates, the assignment is simply active. The canonical field names are: ```text activeDate deactiveDate ``` Avoid introducing aliases like `active_at`, `activeAt`, `active_date`, `inactiveDate`, etc. Keeping one naming convention reduces mapping bugs and makes Hasura/admin/client behavior much easier to reason about. ## High-Level Architecture ```text Admin Panel | | /adm/feature-flags | /adm/bodyshops/:bodyshopId/feature-flags | /adm/feature-flags/:name/bodyshops v Bodyshop Backend | | GraphQL admin client v Hasura/Postgres | | event triggers v Bodyshop Backend cache invalidation route | | updates Redis cache version/key | emits feature-flags-changed v Redis + Socket.IO | | global event or bodyshop room event v Bodyshop Client SocketProvider | | window feature-flags-changed event v Feature flag compatibility layer refetches Bodyshop Client | | imports local feature flag compatibility module v client/src/feature-flags/splitio-react-replacement.jsx | | GET /feature-flags/bodyshops/:bodyshopId v Bodyshop Backend | | Redis read-through cache v Hasura/Postgres when cache misses ``` ## Runtime Request Flow When the bodyshop client needs flags: 1. App code imports hooks/components from the local compatibility layer: - `client/src/feature-flags/splitio-react-replacement.jsx` 2. The compatibility layer reads the active bodyshop from Redux. 3. It requests flags from: - `GET /feature-flags/bodyshops/:bodyshopId` 4. The backend verifies the Firebase user can access that bodyshop through Hasura permissions. 5. The backend checks Redis for cached flags. 6. If Redis has a current-version cache entry, the backend returns it. 7. If Redis misses, the backend queries Hasura for: - active feature flag definitions - assignments for the requested bodyshop 8. The backend combines definitions and assignments into a Split-like payload. 9. The backend writes the payload to Redis with a short TTL. 10. The compatibility layer stores the successful payload in browser `localStorage` as a 24-hour last-known fallback. 11. The compatibility layer evaluates dates and exposes treatments/configs through Split-compatible hooks. 12. If a `feature-flags-changed` socket event arrives later, the compatibility layer refetches the runtime route. ## Database Tables ### `public.feature_flags` This table stores global flag definitions. Important columns: | Column | Type | Meaning | | --- | --- | --- | | `name` | `text` | Primary key. This is the flag key used by the client. | | `description` | `text` | Optional admin-facing explanation. | | `default_treatment` | `text` | Treatment returned when a bodyshop does not have an assignment. Must be non-empty. | | `active` | `boolean` | Globally enables/disables the flag definition. | | `created_at` | `timestamptz` | Creation timestamp. | | `updated_at` | `timestamptz` | Updated by trigger. | Important behavior: - `name` is the stable identifier. Renaming a flag is not currently supported from the admin UI. - `active = false` removes the definition from normal runtime evaluation. - `default_treatment` can be any non-empty text. - Deleting a definition cascades per-bodyshop assignments because of the FK on `bodyshop_feature_flags.name`. ### `public.bodyshop_feature_flags` This table stores per-bodyshop assignments. Important columns: | Column | Type | Meaning | | --- | --- | --- | | `id` | `uuid` | Row primary key. | | `bodyshopid` | `uuid` | Bodyshop receiving this assignment. | | `name` | `text` | Feature flag name. FK to `feature_flags.name`. | | `treatment` | `text` | Treatment returned for this bodyshop. Must be non-empty. | | `config` | `jsonb` | Optional treatment config. | | `activeDate` | `timestamptz` | Optional start timestamp. | | `deactiveDate` | `timestamptz` | Optional end timestamp. | | `created_at` | `timestamptz` | Creation timestamp. | | `updated_at` | `timestamptz` | Updated by trigger. | Important constraints: - Unique assignment per bodyshop and flag: - `(bodyshopid, name)` - `treatment` must be non-empty text. - `deactiveDate` must be after `activeDate` if both are present. - Assignments cascade delete when the bodyshop or feature flag is deleted. ## Hasura Metadata The metadata tracks both new tables. ### `feature_flags` Relationships: - Array relationship to `bodyshop_feature_flags`. Permissions: - `user` role can select active definitions. Event triggers: - `cache_feature_flags` - Fires on insert, update, and delete. - Calls: - `/feature-flags/cache/invalidate` - This causes a global feature flag cache version bump in Redis. ### `bodyshop_feature_flags` Relationships: - Object relationship to `bodyshop`. - Object relationship to `feature_flag`. Permissions: - `user` role can select rows only through active bodyshop associations and active feature flag definitions. Event triggers: - `cache_bodyshop_feature_flags` - Fires on insert, update, and delete. - Calls: - `/feature-flags/cache/invalidate` - This clears the affected bodyshop's current-version feature flag cache key. ## Backend Runtime Route ### `GET /feature-flags/bodyshops/:bodyshopId` Location: ```text server/routes/featureFlagRoutes.js server/feature-flags/feature-flags.js ``` Middleware: - Firebase auth validation. - User GraphQL client. Behavior: 1. Reads `bodyshopId` from the route. 2. Verifies the authenticated user can access the bodyshop using `CHECK_BODYSHOP_ACCESS`. 3. Reads the current feature flag cache version from Redis. 4. Attempts to read: - `bodyshop-feature-flags:v:` 5. If found, returns cached payload with: - `source: "redis"` 6. If not found, queries Hasura using `GET_BODYSHOP_FEATURE_FLAGS`. 7. Builds a payload. 8. Stores it in Redis. 9. Returns payload with: - `source: "database"` Example response: ```json { "bodyshopId": "00000000-0000-0000-0000-000000000000", "flags": { "Enhanced_Payroll": { "treatment": "on", "config": null, "activeDate": null, "deactiveDate": null }, "New_Workflow_Demo": { "treatment": "demo", "config": { "buttonText": "Try it" }, "activeDate": "2026-06-01T13:00:00+00:00", "deactiveDate": "2026-06-05T21:00:00+00:00" } }, "cachedAt": "2026-05-19T15:00:00.000Z", "source": "database" } ``` ### Dev Debug Logging When `NODE_ENV === "development"`, the runtime route logs each hit with level `DEBUG`: ```text feature-flags-route-hit ``` The log metadata includes: - `bodyshopId` - `source` - `redis` - `database` - `flagCount` This is helpful for confirming: - The client is actually hitting the backend. - Redis is being used after the first request. - Cache invalidation causes the next request to read from the database again. ## Backend Cache Invalidation Route ### `POST /feature-flags/cache/invalidate` Location: ```text server/routes/featureFlagRoutes.js server/feature-flags/feature-flags.js ``` Middleware: - Event secret authorization. This route is called by Hasura event triggers. Behavior: - If the event body includes a `bodyshopid`, it invalidates only that bodyshop's current-version cache key. - If no `bodyshopid` is present, it treats the event as a global definition change and increments the global cache version. ### Assignment Change When `bodyshop_feature_flags` changes, the event includes either `event.data.new.bodyshopid` or `event.data.old.bodyshopid`. The backend deletes: ```text bodyshop-feature-flags:v: ``` The next request for that bodyshop reads from Hasura and writes a fresh current-version cache entry. ### Definition Change When `feature_flags` changes, the event has no `bodyshopid`. The backend increments: ```text bodyshop-feature-flags:version ``` Example: ```text bodyshop-feature-flags:version = 12 ``` After increment: ```text bodyshop-feature-flags:version = 13 ``` All old keys such as: ```text bodyshop-feature-flags:v12: ``` become invisible to the runtime route, because it now reads: ```text bodyshop-feature-flags:v13: ``` The old keys are not eagerly deleted. They expire naturally after the configured TTL. ## Redis Cache Details Location: ```text server/utils/redisHelpers.js ``` Important constants: ```text FEATURE_FLAGS_CACHE_TTL = 3600 FEATURE_FLAGS_CACHE_VERSION_KEY = bodyshop-feature-flags:version ``` Important helper functions: - `getBodyshopFeatureFlagsCacheVersion` - `getBodyshopFeatureFlagsFromRedis` - `setBodyshopFeatureFlagsInRedis` - `invalidateBodyshopFeatureFlagsInRedis` - `invalidateAllBodyshopFeatureFlagsInRedis` Cache key format: ```text bodyshop-feature-flags:v: ``` Global version key: ```text bodyshop-feature-flags:version ``` Why versioning is used: - Redis `KEYS bodyshop-feature-flags:*` can block Redis on a large keyspace. - Feature flag definition changes affect all bodyshops. - Incrementing a single version key is constant-time and cheap. - Old entries naturally expire due to the 1 hour TTL. Important operational note: The version key does not need a TTL. It should remain in Redis and monotonically increase. If Redis is flushed, the helper recreates the version key at `1`, and the cache warms again on demand. ## Admin Backend Routes Location: ```text server/routes/adminRoutes.js server/admin/adminops.js ``` ### `GET /adm/feature-flags` Returns feature flag definitions. Query params: - `includeInactive=true` - returns inactive definitions too. Used by: - Bodyshop edit screen. - Feature flag CRUD screen. ### `POST /adm/feature-flags` Creates a new feature flag definition. Typical body: ```json { "name": "New_Workflow_Demo", "description": "Enable the new demo workflow.", "default_treatment": "off", "active": true } ``` After creation, all feature flag caches are invalidated by incrementing the Redis cache version. The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch. ### `PUT /adm/feature-flags/:name` Updates a feature flag definition. The flag `name` is not edited. Other fields can change: - `description` - `default_treatment` - `active` After update, all feature flag caches are invalidated by incrementing the Redis cache version. The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch. ### `DELETE /adm/feature-flags/:name` Deletes a feature flag definition. Because assignments FK to `feature_flags.name` with cascade delete, this also removes bodyshop assignments for that flag. After delete, all feature flag caches are invalidated by incrementing the Redis cache version. The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch. ### `GET /adm/bodyshops/:bodyshopId/feature-flags` Returns assignments for one bodyshop. Used by the bodyshop edit screen. ### `GET /adm/feature-flags/:name/bodyshops` Returns all bodyshops assigned to one flag. Used by the flag CRUD screen's shop assignment drawer. ### `PUT /adm/feature-flags/:name/bodyshops` Replaces the shop assignment list for one flag. Typical body: ```json { "assignments": [ { "bodyshopid": "00000000-0000-0000-0000-000000000000", "treatment": "on", "config": null, "activeDate": null, "deactiveDate": null }, { "bodyshopid": "11111111-1111-1111-1111-111111111111", "treatment": "demo", "config": { "mode": "guided" }, "activeDate": "2026-06-01T13:00:00.000Z", "deactiveDate": "2026-06-05T21:00:00.000Z" } ] } ``` Important behavior: - Assignments present in the request are upserted. - Existing assignments for the flag that are not present in the request are deleted. - Redis caches for changed bodyshops are invalidated. - The backend emits `feature-flags-changed` socket events for changed bodyshops. ### `POST /adm/updateshop` The existing bodyshop update route also saves feature flag assignments. It accepts `bodyshop.featureFlags` and stores those assignments in `bodyshop_feature_flags`, not in the legacy `bodyshops.features.featureFlags` JSON location. This keeps the bodyshop edit screen working while moving feature flags into first-class tables. After assignment changes, Redis caches for the changed bodyshop are invalidated and a bodyshop-scoped `feature-flags-changed` socket event is emitted. ## Admin Panel UI Admin repo: ```text C:\Users\DaveRicher\WebstormProjects\bodyshop-admin ``` ### Bodyshop Edit Screen File: ```text src/components/bodyshop-manage/bodyshop-manage.component.jsx ``` Capabilities: - View assignments for a bodyshop. - Add a flag assignment. - Remove a flag assignment. - Set the assignment treatment. - Set optional `activeDate`. - Set optional `deactiveDate`. This screen is useful when the workflow starts with a bodyshop: > "Turn on `Enhanced_Payroll` for APPLE." ### Feature Flag CRUD Screen File: ```text src/components/feature-flags/feature-flags.component.jsx ``` Route: ```text /feature-flags ``` Capabilities: - View all feature flag definitions. - Search definitions by name/description. - Create a new flag. - Edit an existing flag. - Delete a flag. - See assignment counts. - Open the shops drawer for a flag. - Add/remove bodyshops from a flag. - Set treatment/config/dates for each assigned shop. This screen is useful when the workflow starts with a feature: > "We built `New_Workflow_Demo`; assign it to APPLE, DEMO, and QBO." ### Custom Treatments in Admin Treatment fields use preset suggestions for: - `on` - `off` - `control` Admins can type custom treatment values as well. Examples: ```text variant-a variant-b demo new-ui legacy-ui ``` ## Bodyshop Frontend Compatibility Layer File: ```text client/src/feature-flags/splitio-react-replacement.jsx ``` The client imports the Split-shaped local API: ```js import { useTreatment, useTreatmentsWithConfig } from "../feature-flags/splitio-react-replacement"; ``` The relative path varies by file location. The important part is that all feature flag imports should point to `client/src/feature-flags/splitio-react-replacement.jsx`, not to the old Split package. We no longer rely on a Vite alias to replace that package; code should import our local module directly. Currently supported exports include: - `SplitFactoryProvider` - `useSplitClient` - `useTreatmentsWithConfig` - `useTreatment` - `useTreatmentWithConfig` - `SplitContext` - `useSplitContext` Unknown flags default to `off`. ### Date Evaluation Dates are evaluated on the client compatibility layer. Expected behavior: - No dates: - assignment is active immediately. - `activeDate` only: - assignment is inactive before `activeDate`. - assignment is active at/after `activeDate`. - `deactiveDate` only: - assignment is active before `deactiveDate`. - assignment is inactive at/after `deactiveDate`. - Both dates: - assignment is active in the half-open interval: - `activeDate <= now < deactiveDate` This makes demo windows possible without needing a background job to flip rows. The compatibility layer also schedules a browser timer for the next `activeDate` or `deactiveDate` boundary so an already-open tab reevaluates the flag when the window opens or closes. ### Browser Fallback Cache For outage resilience, the compatibility layer stores the last successful backend flag payload in browser `localStorage`. The browser cache is valid for up to 24 hours. The key is scoped by bodyshop: ```text bodyshop-feature-flags: ``` Fallback order: ```text 1. Backend response from GET /feature-flags/bodyshops/:bodyshopId 2. Last-known browser cache for that bodyshop 3. Unknown flags resolve to off ``` The client does not use old `bodyshops.features.featureFlags` JSON as a feature flag fallback. Feature flags now come from the backend/db-backed system or from the last-known browser cache created from that backend response. ### Live Browser Refresh Feature flag changes are pushed to open browser tabs through the existing Socket.IO infrastructure. Backend emit helper: ```text server/feature-flags/socket-events.js ``` Frontend listener/bridge: ```text client/src/contexts/SocketIO/socketProvider.jsx client/src/feature-flags/splitio-react-replacement.jsx ``` Socket event name: ```text feature-flags-changed ``` Payload shape: ```json { "bodyshopId": "00000000-0000-0000-0000-000000000000", "changedAt": "2026-05-19T15:00:00.000Z", "name": "Enhanced_Payroll", "scope": "bodyshop", "source": "admin", "table": "bodyshop_feature_flags" } ``` Important fields: - `scope` - `global`: every open tab should refetch. - `bodyshop`: only tabs for the matching `bodyshopId` should refetch. - `source` - `admin`: emitted after admin backend writes. - `hasura`: emitted after Hasura event-trigger invalidation. - `table` - `feature_flags`: global definition change. - `bodyshop_feature_flags`: per-bodyshop assignment change. Backend behavior: - Definition changes emit globally with `scope: "global"`. - Assignment changes emit to the bodyshop room when a bodyshop id is known. - If a bodyshop room helper is unavailable, the fallback room name is: ```text bodyshop-broadcast-room: ``` Frontend behavior: - `SocketProvider` listens for the socket event. - It bridges the socket event into a browser `CustomEvent` with the same event name. - `SplitFactoryProvider` listens for that browser event. - Global events always trigger a refetch. - Bodyshop events trigger a refetch only when `payload.bodyshopId` matches the active Redux bodyshop. - Refetches are debounced for 150 ms to collapse duplicate admin/Hasura events. If the refetch fails because the backend is unreachable, the normal browser fallback cache behavior still applies. ## Harness/Split Export and Import Exporter script: ```text scripts/export-harness-feature-flags.js ``` NPM script: ```text npm run feature-flags:export-harness ``` Output folder: ```text harness-feature-flags-export ``` Important output files: - `feature_flags.json` - `bodyshop_feature_flags.json` - `global_defaults.json` - `unmapped_rules.json` - `raw/sdk_split_changes.json` - `bodyshop_feature_flags_import.sql` The import SQL maps Harness/Split target keys to bodyshops by matching: ```sql lower("bodyshops"."imexshopid") = lower("exported_flags"."imexshopid") ``` This matters because Harness targeting was based on `imexshopid`, not the bodyshop UUID. The generated SQL also reports: - exported Harness target keys that did not match any `bodyshops.imexshopid` - exported feature flag names that did not already exist in `public.feature_flags` The importer preserves custom treatment names and casts missing configs as `NULL::jsonb`, which avoids Postgres treating the `config` column as text during import. ### Export Completeness Caveat The exporter can only export the project/environment visible to the supplied key. If the key is an SDK key, it exports what that SDK key can read. It does not prove that every Harness project, environment, or SDK key has been exported. For complete migration confidence, collect either: - a Harness Admin API key with access to the relevant projects/environments, or - all SDK keys used by the environments we care about. ## Applying DB Changes The feature flag migrations are in: ```text hasura/migrations ``` Important migrations: ```text 1778870000000_create_table_public_feature_flags 1778950000000_create_table_public_bodyshop_feature_flags 1779040000000_allow_custom_feature_flag_treatments ``` After migrations, apply Hasura metadata so relationships, permissions, and event triggers are active. Important metadata file: ```text hasura/metadata/tables.yaml ``` After applying migrations/metadata: 1. Restart the backend. 2. Restart the admin panel if needed. 3. Create or edit a flag in admin. 4. Assign it to a bodyshop. 5. Request runtime flags for that bodyshop. ## Recommended Smoke Test Use this flow after applying DB changes or changing cache behavior. ### 1. Create a Test Flag In admin: - Go to `/feature-flags`. - Create: ```text name: Demo_Test_Flag default_treatment: off active: true ``` ### 2. Assign a Bodyshop From the same flag screen: - Open `Shops`. - Add one test bodyshop. - Set treatment: ```text on ``` Optional: - Add an `activeDate`. - Add a `deactiveDate`. - Add config JSON. ### 3. Hit Runtime Route Call: ```text GET /feature-flags/bodyshops/:bodyshopId ``` Expected first response: ```text source: database ``` Expected payload: ```json { "flags": { "Demo_Test_Flag": { "treatment": "on" } } } ``` ### 4. Hit Runtime Route Again Expected second response: ```text source: redis ``` ### 5. Change the Assignment In admin: - Change treatment from `on` to `off`, or to a custom treatment like `demo`. - Save. Expected next runtime response: ```text source: database ``` The changed bodyshop's cache should have been invalidated. Any open browser tab for that bodyshop should update without a manual refresh. ### 6. Change the Definition In admin: - Change the flag description, active state, or default treatment. - Save. Expected behavior: - Redis global feature flag cache version increments. - Next runtime request for any bodyshop reads from database and stores under the new versioned key. - Any open browser tab should receive a global socket event and refetch. ### 7. Confirm the Footer Test Indicator The bodyshop frontend footer has a temporary manual test hook: ```text client/src/components/global-footer/global-footer.component.jsx ``` When `TEST_FLAG` resolves to treatment `on`, the footer displays: ```text Test Feature Flag Enabled ``` When `TEST_FLAG` is `off`, missing, globally inactive, outside its schedule window, or unavailable with no browser cache, the footer indicator is hidden. ## Troubleshooting ### Admin Cannot See Flags Check: - Backend is restarted. - Hasura migrations are applied. - Hasura metadata is applied. - `/adm/feature-flags` is registered in `server/routes/adminRoutes.js`. - Admin user passes `validateAdminMiddleware`. ### Runtime Route Returns 403 The backend verifies bodyshop access with the user's Hasura permissions. Check: - The Firebase token is valid. - The user has an active association to the bodyshop. - The bodyshop id in the route is correct. ### Runtime Route Returns No Custom Flag Check: - The flag definition exists in `feature_flags`. - The flag definition has `active = true`. - The bodyshop assignment exists in `bodyshop_feature_flags`. - The assignment dates are not excluding the current time. - The bodyshop id is the UUID, not `imexshopid`. ### Assignment Save Fails with Date Constraint If both dates are present: ```text deactiveDate > activeDate ``` must be true. ### Config Save Fails Config must be valid JSON. Valid: ```json { "limit": 10 } ``` Invalid: ```json { limit: 10 } ``` ### Treatment Save Fails Treatment must be non-empty text. Valid: ```text on off control variant-a ``` Invalid: ```text ``` ### Cache Does Not Seem to Invalidate Check whether the route response has: ```text source: redis ``` or: ```text source: database ``` In development, look for `feature-flags-route-hit` debug logs. For assignment changes: - Ensure the `cache_bodyshop_feature_flags` Hasura event trigger exists. - Ensure event secret is correct. - Ensure the event includes `bodyshopid`. For definition changes: - Ensure the `cache_feature_flags` Hasura event trigger exists. - Ensure event secret is correct. - Confirm Redis version changes: ```text bodyshop-feature-flags:version ``` ### Old Cache Keys Remain in Redis This is expected. Old versioned keys are intentionally not deleted during global invalidation. They become invisible because the runtime route reads the new version. They expire naturally after `FEATURE_FLAGS_CACHE_TTL`, currently 1 hour. ## Operational Notes ### Renaming Flags Flag names are primary keys and are not editable in the admin UI. If a flag must be renamed: 1. Create the new flag. 2. Reassign bodyshops to the new flag. 3. Update code references. 4. Delete the old flag once no code uses it. Do not rename a flag directly in the database unless all client code references are updated at the same time. ### Deleting Flags Deleting a flag definition cascades assignments. This is usually what we want, but be careful with production flags. If unsure, set `active = false` first and observe behavior before deleting. ### Default Treatments Defaults apply only when a bodyshop has no explicit assignment for a flag. Example: - `feature_flags.default_treatment = off` - no `bodyshop_feature_flags` row - runtime treatment is `off` If an assignment exists, assignment treatment wins. ### Active Flag Definition vs Assignment Schedule Both layers matter: - Definition `active = false` means the flag does not appear in runtime definitions. - Assignment dates determine whether a bodyshop's assigned treatment is currently active. For demo scheduling, keep the definition active and place the window on the assignment. ### Bodyshop ID vs Imex Shop ID Runtime route uses bodyshop UUID: ```text /feature-flags/bodyshops/:bodyshopId ``` Harness export/import maps from `imexshopid` because Harness target keys used that value. Do not mix these up: - Runtime and database assignments use `bodyshopid` UUID. - Migration/import matching uses `imexshopid`. ## Relevant Files Bodyshop backend: ```text server/feature-flags/feature-flags.js server/routes/featureFlagRoutes.js server/admin/adminops.js server/routes/adminRoutes.js server/utils/redisHelpers.js server/graphql-client/queries.js server.js ``` Bodyshop client: ```text client/vite.config.js client/src/feature-flags/splitio-react-replacement.jsx client/src/feature-flags/README.md ``` Hasura: ```text hasura/migrations/1778870000000_create_table_public_feature_flags hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags hasura/migrations/1779040000000_allow_custom_feature_flag_treatments hasura/metadata/tables.yaml ``` Admin panel: ```text ../bodyshop-admin/src/components/feature-flags/feature-flags.component.jsx ../bodyshop-admin/src/components/bodyshop-manage/bodyshop-manage.component.jsx ../bodyshop-admin/src/components/router/router.component.jsx ../bodyshop-admin/src/components/main-sider/main-sider.component.jsx ``` Harness export: ```text scripts/export-harness-feature-flags.js harness-feature-flags-export ``` ## Future Improvements These are not required for the current rollout, but they are worth considering. ### Audit Trail Feature flags control production behavior. It would be useful to record: - who created a flag - who edited a definition - who assigned or removed a bodyshop - old value - new value - timestamp This can be a dedicated audit table or integrated with the existing audit logging pattern if appropriate. ### Export Completeness The current Harness export path should be repeated for every project/environment/key that matters, or replaced with an Admin API export if Harness access allows it. ### More Tests Useful additional automated coverage: - A fuller admin route integration test that exercises create, update, delete, and assignment replacement together. - A browser-level smoke test that toggles `TEST_FLAG` in admin and observes the footer update without a page refresh. - Importer coverage for multiple Harness environments once we have more than one export key.