Files
bodyshop/_reference/feature-flags.md

1298 lines
31 KiB
Markdown

# 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<version>:<bodyshopId>`
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<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:
```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:<bodyshopId>
```
become invisible to the runtime route, because it now reads:
```text
bodyshop-feature-flags:v13:<bodyshopId>
```
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<version>:<bodyshopId>
```
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:<bodyshopId>
```
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:<bodyshopId>
```
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.