--- name: unleash-flag description: This skill should be used when the user asks to "add a feature flag", "gate a feature", "put this behind a flag", "add a workspace flag", "feature flag this", "make this configurable per workspace", "configure Unleash", or references "flags.json", "useWorkspaceFlag", "useFlag", "SyncFlags", or "controlled rollout" of a feature. --- # Unleash Feature Flag Workspace-scoped feature flags for the Ambient Code Platform — naming, gating, evaluation, and lifecycle management. ## Checklist Every new flag requires these steps. Skip none. 1. **Define the flag** in `components/manifests/base/core/flags.json` 2. **Gate the frontend** using the correct hook for scope 3. **Update tests** to mock the flag hook 4. **Verify** type-check and tests pass ## 1. Define the Flag Add to `components/manifests/base/core/flags.json`: ```json { "name": "category.feature-name.enabled", "description": "Human-readable purpose of this flag", "tags": [ { "type": "scope", "value": "workspace" } ] } ``` ### Naming Convention **All new flags** must use the `category.feature-name.enabled` format: ``` category.feature-name.enabled ``` | Segment | Rules | Examples | |---------|-------|---------| | `category` | One of the standard categories below | `runner`, `model`, `integration` | | `feature-name` | Lowercase, hyphen-separated, descriptive | `gemini-cli`, `coderabbit` | | `.enabled` | Required suffix for all new flags | — | **Standard categories:** | Category | When | Example | |----------|------|---------| | `integration` | External service integrations | `integration.coderabbit.enabled` | | `runner` | Runner type availability | `runner.gemini-cli.enabled` | | `model` | Model availability (auto-generated by SyncFlags) | `model.claude-sonnet-4-5.enabled` | | `feature` | Platform capabilities | `feature.scheduled-export.enabled` | **Grandfathered flags** (do not rename): - `jira-write` - `ldap.autocomplete.enabled` - `scheduled-session.reuse.enabled` These predate the convention. New flags must not follow their patterns. **Rules:** - **Unique across the entire system** — never reuse a retired flag name - **Descriptive** — someone unfamiliar should understand the scope from the name alone - **Dot-delimited** with category prefix — enables grouping in workspace settings UI - **Lowercase with hyphens** within segments (not underscores, not camelCase) ### Workspace Settings Visibility The `scope:workspace` tag is what makes a flag appear in the workspace settings UI. Without it, the flag exists in Unleash but workspace admins cannot toggle it. Backend `SyncFlags` runs at startup: reads `flags.json`, creates flags in Unleash (type: `release`, disabled by default, 0% rollout), and tags them. Flags without `EnabledByDefault: true` start **off**. To force a re-sync, restart the backend. To clear a stuck flag, archive + purge it in Unleash admin before restarting. ## 2. Gate the Frontend ### Choosing the Right Hook **Default: `useWorkspaceFlag`** — always prefer this hook. It respects ConfigMap overrides set by workspace admins. ```tsx import { useWorkspaceFlag } from "@/services/queries/use-feature-flags-admin"; const { enabled } = useWorkspaceFlag(projectName, "category.feature.enabled"); ``` **Exception: `useFlag`** — only for the rare case where no project context is available (e.g., cluster-level admin pages like `/integrations`). It bypasses workspace overrides entirely and returns only the Unleash global default. Do not use it in project-scoped pages. ```tsx import { useFlag } from "@/lib/feature-flags"; const enabled = useFlag("category.feature.enabled"); ``` | Hook | Scope | Evaluation | |------|-------|------------| | `useWorkspaceFlag` | Project-scoped pages (default) | ConfigMap override > Unleash default | | `useFlag` | Cluster-scoped pages only (exception) | Unleash default only | ### Gating Patterns **Conditionally render a component:** ```tsx const { enabled: featureEnabled } = useWorkspaceFlag(projectName, "category.feature.enabled"); // In JSX: {featureEnabled && } ``` **Conditionally include in an array:** ```tsx const items = [ { key: "always-visible", name: "Always" }, ...(featureEnabled ? [{ key: "gated", name: "Gated Feature" }] : []), ]; ``` **Extract project name from route params** (when not passed as prop): ```tsx import { useParams } from "next/navigation"; const params = useParams(); const projectName = params.name as string; ``` ### Backend Gating (when needed) For full-stack gating (not always necessary — frontend-only is often sufficient): ```go // Global check if !FeatureEnabled("category.feature.enabled") { c.JSON(http.StatusForbidden, gin.H{"error": "Feature not enabled"}) return } // Request-scoped (includes user/IP context for Unleash strategies) if !FeatureEnabledForRequest(c, "category.feature.enabled") { c.JSON(http.StatusForbidden, gin.H{"error": "Feature not enabled"}) return } ``` ### Backend Middleware Gating For endpoints that should be entirely hidden behind a flag, create a middleware in `routes.go` that checks `FeatureEnabledForRequest()` and returns 404 when the flag is off: ```go // routes.go — gate an entire route group flagged := router.Group("/api/v1/feature") flagged.Use(func(c *gin.Context) { if !handlers.FeatureEnabledForRequest(c, "category.feature.enabled") { c.AbortWithStatus(http.StatusNotFound) return } c.Next() }) { flagged.GET("/resource", handlers.ListResource) flagged.POST("/resource", handlers.CreateResource) } ``` This returns 404 (not 403) when the flag is off, so the endpoint appears not to exist. Use this for features that shouldn't even be discoverable until enabled. ### E2E Testing with Feature Flags When writing E2E tests for flagged features, the Unleash admin token must be set as an environment variable. **Never paste or export the token inline in a shell session** — this risks exposing it in shell history, logs, or terminal recordings. Obtain the token from Unleash UI > API Access or from deployment secrets, then set it using a non-echoing prompt or a secret manager: ```bash # Non-echoing prompt (token not stored in shell history) read -rs CYPRESS_UNLEASH_ADMIN_TOKEN && export CYPRESS_UNLEASH_ADMIN_TOKEN ``` In CI, inject it via a secret reference (e.g. GitHub Actions secret, Vault, or sealed secret) — never hard-code or `echo` the value. > **Note**: When logging token-related errors, use `len(token)` to confirm presence without exposing the value. Never include the full token in error messages, API responses, or commit history. Map it in `e2e/cypress.config.ts`: ```typescript env: { UNLEASH_ADMIN_TOKEN: process.env.CYPRESS_UNLEASH_ADMIN_TOKEN } ``` ```typescript // e2e/cypress/e2e/flagged-feature.cy.ts describe('Flagged Feature', () => { before(() => { // Enable the flag via Unleash admin API cy.request({ method: 'POST', url: 'http://localhost:4242/api/admin/projects/default/features/category.feature.enabled/environments/development/on', headers: { Authorization: Cypress.env('UNLEASH_ADMIN_TOKEN') }, }).its('status').should('eq', 200); }); after(() => { // Disable the flag after tests cy.request({ method: 'POST', url: 'http://localhost:4242/api/admin/projects/default/features/category.feature.enabled/environments/development/off', headers: { Authorization: Cypress.env('UNLEASH_ADMIN_TOKEN') }, }).its('status').should('eq', 200); }); it('renders when flag is enabled', () => { // ... test the feature }); }); ``` ## 3. Update Tests Any component that now calls `useWorkspaceFlag` or `useFlag` needs its tests updated. **Mock `useWorkspaceFlag`:** ```tsx vi.mock("@/services/queries/use-feature-flags-admin", () => ({ useWorkspaceFlag: () => ({ enabled: true }), })); ``` **Mock `useParams`** (if newly added): ```tsx vi.mock("next/navigation", () => ({ useParams: () => ({ name: "test-project" }), })); ``` **Mock `useFlag`:** ```tsx vi.mock("@/lib/feature-flags", () => ({ useFlag: () => true, })); ``` Test both states: confirm the gated element renders when enabled, and is absent when disabled. ## 4. Verify ```bash cd components/frontend && npx tsc --noEmit # Type-check cd components/frontend && npx vitest run # Unit tests ``` Both must pass with zero errors. ## Evaluation Order When a flag is evaluated at runtime, the platform checks in priority order: 1. **ConfigMap override** (`feature-flag-overrides` in workspace namespace) — set by workspace admin via settings UI 2. **Unleash default** — global state from Unleash server 3. **Code default** — `false` (fail-closed) This means a workspace admin can override the global Unleash state in either direction. ## Lifecycle | Phase | Action | |-------|--------| | **Create** | Add to `flags.json`, gate frontend, assign ownership | | **Rollout** | Workspace admins enable per-workspace via settings UI | | **GA** | Remove flag checks from code, remove from `flags.json`, create Jira for cleanup tracking | | **Cleanup** | Archive flag in Unleash, remove stale ConfigMap overrides | Treat flags as technical debt. When a feature is fully rolled out, remove the flag — don't leave it permanently enabled. **When a feature reaches GA**, create a Jira issue (use `/jira-log`) to track cleanup in this order: 1. Verify feature is fully rolled out (all workspaces enabled or no longer need it) 2. Remove `useWorkspaceFlag` / `useFlag` calls from code and deploy 3. Remove the flag from `flags.json` and deploy (SyncFlags stops recreating it) 4. Remove any ConfigMap overrides in workspace namespaces 5. Archive and purge the flag in Unleash **Exceptions for long-lived flags:** kill switches for graceful degradation, and debug flags for expensive tracing. ## Common Mistakes | Mistake | Fix | |---------|-----| | Using `useFlag()` in project-scoped page | Use `useWorkspaceFlag(projectName, flagName)` | | Missing `scope:workspace` tag | Flag won't appear in workspace settings UI | | Forgetting to mock hook in tests | Tests crash with "Cannot read properties of null" | | Reusing a retired flag name | Unleash conflict error on sync — archive + purge first | | Checking flag in multiple places | Evaluate once, pass result down | | No test for disabled state | Add test verifying gated element is hidden when flag is off |