31 KiB
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
activeDateanddeactiveDatescheduling on per-bodyshop assignments. - Custom treatment values, not just
on,off, andcontrol. - 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 EnabledwhenTEST_FLAGison.
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:
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:
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:
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:
{
"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:
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
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:
- App code imports hooks/components from the local compatibility layer:
client/src/feature-flags/splitio-react-replacement.jsx
- The compatibility layer reads the active bodyshop from Redux.
- It requests flags from:
GET /feature-flags/bodyshops/:bodyshopId
- The backend verifies the Firebase user can access that bodyshop through Hasura permissions.
- The backend checks Redis for cached flags.
- If Redis has a current-version cache entry, the backend returns it.
- If Redis misses, the backend queries Hasura for:
- active feature flag definitions
- assignments for the requested bodyshop
- The backend combines definitions and assignments into a Split-like payload.
- The backend writes the payload to Redis with a short TTL.
- The compatibility layer stores the successful payload in browser
localStorageas a 24-hour last-known fallback. - The compatibility layer evaluates dates and exposes treatments/configs through Split-compatible hooks.
- If a
feature-flags-changedsocket 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:
nameis the stable identifier. Renaming a flag is not currently supported from the admin UI.active = falseremoves the definition from normal runtime evaluation.default_treatmentcan 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)
treatmentmust be non-empty text.deactiveDatemust be afteractiveDateif 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:
userrole 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:
userrole 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:
server/routes/featureFlagRoutes.js
server/feature-flags/feature-flags.js
Middleware:
- Firebase auth validation.
- User GraphQL client.
Behavior:
- Reads
bodyshopIdfrom the route. - Verifies the authenticated user can access the bodyshop using
CHECK_BODYSHOP_ACCESS. - Reads the current feature flag cache version from Redis.
- Attempts to read:
bodyshop-feature-flags:v<version>:<bodyshopId>
- If found, returns cached payload with:
source: "redis"
- If not found, queries Hasura using
GET_BODYSHOP_FEATURE_FLAGS. - Builds a payload.
- Stores it in Redis.
- Returns payload with:
source: "database"
Example response:
{
"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:
feature-flags-route-hit
The log metadata includes:
bodyshopIdsourceredisdatabase
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:
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
bodyshopidis 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:
bodyshop-feature-flags:v<currentVersion>:<bodyshopId>
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:
bodyshop-feature-flags:version
Example:
bodyshop-feature-flags:version = 12
After increment:
bodyshop-feature-flags:version = 13
All old keys such as:
bodyshop-feature-flags:v12:<bodyshopId>
become invisible to the runtime route, because it now reads:
bodyshop-feature-flags:v13:<bodyshopId>
The old keys are not eagerly deleted. They expire naturally after the configured TTL.
Redis Cache Details
Location:
server/utils/redisHelpers.js
Important constants:
FEATURE_FLAGS_CACHE_TTL = 3600
FEATURE_FLAGS_CACHE_VERSION_KEY = bodyshop-feature-flags:version
Important helper functions:
getBodyshopFeatureFlagsCacheVersiongetBodyshopFeatureFlagsFromRedissetBodyshopFeatureFlagsInRedisinvalidateBodyshopFeatureFlagsInRedisinvalidateAllBodyshopFeatureFlagsInRedis
Cache key format:
bodyshop-feature-flags:v<version>:<bodyshopId>
Global version key:
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:
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:
{
"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:
descriptiondefault_treatmentactive
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:
{
"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-changedsocket 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:
C:\Users\DaveRicher\WebstormProjects\bodyshop-admin
Bodyshop Edit Screen
File:
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_Payrollfor APPLE."
Feature Flag CRUD Screen
File:
src/components/feature-flags/feature-flags.component.jsx
Route:
/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:
onoffcontrol
Admins can type custom treatment values as well.
Examples:
variant-a
variant-b
demo
new-ui
legacy-ui
Bodyshop Frontend Compatibility Layer
File:
client/src/feature-flags/splitio-react-replacement.jsx
The client imports the Split-shaped local API:
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:
SplitFactoryProvideruseSplitClientuseTreatmentsWithConfiguseTreatmentuseTreatmentWithConfigSplitContextuseSplitContext
Unknown flags default to off.
Date Evaluation
Dates are evaluated on the client compatibility layer.
Expected behavior:
- No dates:
- assignment is active immediately.
activeDateonly:- assignment is inactive before
activeDate. - assignment is active at/after
activeDate.
- assignment is inactive before
deactiveDateonly:- assignment is active before
deactiveDate. - assignment is inactive at/after
deactiveDate.
- assignment is active before
- Both dates:
- assignment is active in the half-open interval:
activeDate <= now < deactiveDate
- assignment is active in the half-open interval:
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:
bodyshop-feature-flags:<bodyshopId>
Fallback order:
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:
server/feature-flags/socket-events.js
Frontend listener/bridge:
client/src/contexts/SocketIO/socketProvider.jsx
client/src/feature-flags/splitio-react-replacement.jsx
Socket event name:
feature-flags-changed
Payload shape:
{
"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:
scopeglobal: every open tab should refetch.bodyshop: only tabs for the matchingbodyshopIdshould refetch.
sourceadmin: emitted after admin backend writes.hasura: emitted after Hasura event-trigger invalidation.
tablefeature_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:
bodyshop-broadcast-room:<bodyshopId>
Frontend behavior:
SocketProviderlistens for the socket event.- It bridges the socket event into a browser
CustomEventwith the same event name. SplitFactoryProviderlistens for that browser event.- Global events always trigger a refetch.
- Bodyshop events trigger a refetch only when
payload.bodyshopIdmatches 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:
scripts/export-harness-feature-flags.js
NPM script:
npm run feature-flags:export-harness
Output folder:
harness-feature-flags-export
Important output files:
feature_flags.jsonbodyshop_feature_flags.jsonglobal_defaults.jsonunmapped_rules.jsonraw/sdk_split_changes.jsonbodyshop_feature_flags_import.sql
The import SQL maps Harness/Split target keys to bodyshops by matching:
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:
hasura/migrations
Important migrations:
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:
hasura/metadata/tables.yaml
After applying migrations/metadata:
- Restart the backend.
- Restart the admin panel if needed.
- Create or edit a flag in admin.
- Assign it to a bodyshop.
- 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:
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:
on
Optional:
- Add an
activeDate. - Add a
deactiveDate. - Add config JSON.
3. Hit Runtime Route
Call:
GET /feature-flags/bodyshops/:bodyshopId
Expected first response:
source: database
Expected payload:
{
"flags": {
"Demo_Test_Flag": {
"treatment": "on"
}
}
}
4. Hit Runtime Route Again
Expected second response:
source: redis
5. Change the Assignment
In admin:
- Change treatment from
ontooff, or to a custom treatment likedemo. - Save.
Expected next runtime response:
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:
client/src/components/global-footer/global-footer.component.jsx
When TEST_FLAG resolves to treatment on, the footer displays:
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-flagsis registered inserver/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:
deactiveDate > activeDate
must be true.
Config Save Fails
Config must be valid JSON.
Valid:
{
"limit": 10
}
Invalid:
{
limit: 10
}
Treatment Save Fails
Treatment must be non-empty text.
Valid:
on
off
control
variant-a
Invalid:
Cache Does Not Seem to Invalidate
Check whether the route response has:
source: redis
or:
source: database
In development, look for feature-flags-route-hit debug logs.
For assignment changes:
- Ensure the
cache_bodyshop_feature_flagsHasura event trigger exists. - Ensure event secret is correct.
- Ensure the event includes
bodyshopid.
For definition changes:
- Ensure the
cache_feature_flagsHasura event trigger exists. - Ensure event secret is correct.
- Confirm Redis version changes:
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:
- Create the new flag.
- Reassign bodyshops to the new flag.
- Update code references.
- 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_flagsrow - runtime treatment is
off
If an assignment exists, assignment treatment wins.
Active Flag Definition vs Assignment Schedule
Both layers matter:
- Definition
active = falsemeans 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:
/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
bodyshopidUUID. - Migration/import matching uses
imexshopid.
Relevant Files
Bodyshop backend:
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:
client/vite.config.js
client/src/feature-flags/splitio-react-replacement.jsx
client/src/feature-flags/README.md
Hasura:
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:
../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:
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_FLAGin admin and observes the footer update without a page refresh. - Importer coverage for multiple Harness environments once we have more than one export key.