Files
bodyshop/_reference/feature-flags.md

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

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:

  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:

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:

{
  "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:

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

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:

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:

  • getBodyshopFeatureFlagsCacheVersion
  • getBodyshopFeatureFlagsFromRedis
  • setBodyshopFeatureFlagsInRedis
  • invalidateBodyshopFeatureFlagsInRedis
  • invalidateAllBodyshopFeatureFlagsInRedis

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:

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

{
  "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:

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_Payroll for 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:

  • on
  • off
  • control

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:

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

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:

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

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

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:

  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.

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 on to off, or to a custom treatment like demo.
  • 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.

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

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

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

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_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.