Feature Flags Authoring
A developer how-to for the full lifecycle of a Packmind feature flag: add, edit, remove. Flags gate a feature by email domain (or exact email). The same decision function runs on frontend and backend, so behavior never diverges. Follow the steps below exactly — every flag touches three places.
For inventorying existing flags, orphan detection, and audience audits, use the feature-flags-audit skill instead. This skill changes flags; that one scans them.
Where things live
- Shared registry —
@packmind/feature-flags(pure,env:shared, browser-safe). The single source of truth:registry.ts— the*_FEATURE_KEYconstants,DEFAULT_FEATURE_DOMAIN_MAP(Record<string, readonly string[]>mapping each flag's string value to allowed entries), and theFeatureFlagKeyunion.isFeatureFlagEnabled.ts— the pure evaluator:isFeatureFlagEnabled({ featureKeys, featureDomainMap, userEmail }).
- Frontend gate —
<PMFeatureFlag>from@packmind/ui(or a directisFeatureFlagEnabled(...)call). The keys,DEFAULT_FEATURE_DOMAIN_MAP, andisFeatureFlagEnabledare re-exported from@packmind/uifor back-compat, so existing consumers can import from either place. - Backend gate —
isFeatureEnabled(flag, { userEmail }), a plain async helper from@packmind/node-utils. It resolves the env kill-switch first, then defers to the shared email-domain rule. It is NOT a port and has no DI — import it and call it. - Env kill-switch —
FF_<SCREAMING_SNAKE>(e.g.FF_CHANGE_PROPOSALS_IN_WEBAPP).on/all/trueforce the flag on;off/none/falseforce it off; unset or empty falls through to the email-domain map.
Two flags exist today: 'change-proposals-in-webapp' and 'orga-space-management'.
Add a flag
1. Register it in @packmind/feature-flags
Add the key constant, the audience entry, and extend the union. Do all three — a constant with no map entry is never enabled for anyone; a map entry with no union member can't be typed at call sites.
// registry.ts
export const MY_FEATURE_KEY = 'my-feature' as const;
export type FeatureFlagKey =
| 'change-proposals-in-webapp'
| 'orga-space-management'
| 'my-feature';
export const DEFAULT_FEATURE_DOMAIN_MAP: Record<string, readonly string[]> = {
// ...
'my-feature': ['@packmind.com'], // domains keep the `@`; exact emails have no leading `@`
};
2. Gate the frontend
Wrap the UI that should appear only when the flag is on:
import { PMFeatureFlag } from '@packmind/ui';
import { MY_FEATURE_KEY, DEFAULT_FEATURE_DOMAIN_MAP } from '@packmind/feature-flags';
<PMFeatureFlag
featureKeys={[MY_FEATURE_KEY]}
featureDomainMap={DEFAULT_FEATURE_DOMAIN_MAP}
userEmail={currentUser.email}
>
{/* gated UI */}
</PMFeatureFlag>
For a branch (route, tab, boolean), call isFeatureFlagEnabled({ featureKeys: [MY_FEATURE_KEY], featureDomainMap: DEFAULT_FEATURE_DOMAIN_MAP, userEmail }) directly.
3. Gate the backend
Inside a member use case, command.user.email is available. Branch on the helper:
import { isFeatureEnabled } from '@packmind/node-utils';
import { MY_FEATURE_KEY } from '@packmind/feature-flags';
if (await isFeatureEnabled(MY_FEATURE_KEY, { userEmail: command.user.email })) {
// new path
} else {
// old path
}
4. (Optional) Env override
Set FF_MY_FEATURE=on to force the flag on everywhere, or FF_MY_FEATURE=off to force it off, regardless of the domain map. Leave unset to defer to the map. Document the var in the deploy config if you rely on it.
Edit a flag
Change the allowed audience in DEFAULT_FEATURE_DOMAIN_MAP — add or remove domains (@company.com) or exact emails (user@company.com). This is the only place audience lives; the frontend and backend both read it.
Remember: the FF_* env kill-switch overrides the map at runtime. If a flag looks stuck on or off in an environment, check the env var before editing the map.
Remove a flag
- In
@packmind/feature-flags: delete the*_FEATURE_KEYconstant, itsDEFAULT_FEATURE_DOMAIN_MAPentry, and itsFeatureFlagKeyunion member. - Delete every call site:
- Frontend: remove the
<PMFeatureFlag>wrappers (keep the children — the UI ships unconditionally now) and anyisFeatureFlagEnabledbranches. - Backend: remove the
isFeatureEnabledbranches, keeping the winning branch inline.
- Frontend: remove the
- Drop any
FF_<SCREAMING_SNAKE>env var from deploy config. - Run the
feature-flags-auditskill to confirm no orphaned keys, map entries, or call sites remain.
Conventions
- Audience is email-domain only (Proposal A). No org, plan, team, or percentage targeting exists yet — do not describe or build it.
- Never log an email in clear text. If you log a flag decision, mask the email (first 6 chars +
*) or, better, log only the flag key and the boolean outcome. - Keep
@packmind/feature-flagspure and browser-safe. Noenv:nodeimports (no@packmind/node-utils, noConfiguration.getConfig, noprocess.env). It ships to the browser bundle. - The backend gate is a plain helper call, not an injected port. Import
isFeatureEnabledand call it directly; do not add a port/adapter/registry.
See also
feature-flags-audit— the review/scan counterpart: inventories every flag, its audience, its usages, and flags orphans. Run it after removing a flag and whenever you need the current state of the system.