Feature flags

The repository’s own convention, stated in capitals in the README: feature flags "SHOULD BE USED ON ALL MODULES BY DEFAULT". New modules and routes are expected to be flag-gated. See decision D7.

The vocabulary

src/app/shared/feature-flags/feature-flags.const.ts holds the FEATURE_FLAGS enum — the single list of valid flag names. Always add to it; never use a bare string.

Current flags and what they gate:

Flag Gates

SUPPLIER_ACCOUNT_HEALTH

The supplier health tab and health cards.

TASKS_MID_VER_SIMPLE_QUERY

A simpler task-counter query in TaskCountBadgeComponent.

UNDERWRITING_TRACKER

The /underwriting tracker menu item.

BANK_CATEGORIES

The bank-transaction-categories underwriting tab.

EVA_ONBOARDING_SUMMARY

EVA-rendered onboarding summary.

ACCESS_ONBOARDING_PREDICTIONS

The onboarding ML-prediction underwriting tab.

ACCESS_DAILY_PREDICTIONS

The daily ML-prediction underwriting tab.

TWO_FA

Supplier two-factor management.

UNDERWRITING_CHECK_TOOLS_BEFORE_APPROVING

Requires UW tool results before approval.

PYB_DOCUMENTS

The PYB documents backend.

EVA_SUPPLIER_MENU

EVA-rendered supplier menu.

CHECK_VPN

Starts VpnTesterService.

DELETE_SUPPLIER

Supplier account deletion.

CARD_MANAGEMENT_AIRWALLEX

The Airwallex card programme.

Also relevant, in feature-flags.enum.ts:

export enum FEATURE_FLAGS_ENV { IA = 'IA', EVA = 'EVA' }

export enum FEATURE_FLAGS_ENV_GLOBAL_SETTINGS_KEY {
  IA  = 'FEATURE_FLAGS_IA',
  EVA = 'FEATURE_FLAGS_EVA',
}

Flags are managed for two applications: this one and EVA.

The three layers

┌───────────────────────────────────────────────────────────────────┐
│ 1. BUILD          environment.featureFlags                        │
│                   a comma-separated string, no spaces             │
│                   baked in by deployment/set-env.ts from          │
│                   $FEATURE_FLAGS at build time                    │
│                   → FeatureFlagsService.featureFlags              │
├───────────────────────────────────────────────────────────────────┤
│ 2. GLOBAL SETTINGS  Actions Tools setting FEATURE_FLAGS_IA / _EVA  │
│                   config_value = { flags: 'A,B,C', lastChange: n } │
│                   environment-wide, editable in DEV Admin          │
├───────────────────────────────────────────────────────────────────┤
│ 3. SESSION        sessionStorage['FeatureExecFlags']               │
│                   + ['FeatureExecFlagsLastChange']                │
│                   (EVA: 'FeatureExecFlagsEva' / …'LastChangeEva')  │
│                   per-tab overrides, set in DEV Admin              │
└───────────────────────────────────────────────────────────────────┘
                              ↓
                     resolved "exec" set

An empty featureFlags string means all build flags are disabled — flags are opt-in, never opt-out.

Precedence

FeatureFlagsService.getRecalculatedFlagsStatus() decides. For the IA environment:

  1. availableFlags = every member of the FEATURE_FLAGS enum.

  2. buildProvidedFlags = parsed environment.featureFlags.

  3. globalSettingsProvidedFlags = parsed from the FEATURE_FLAGS_IA setting.

  4. execFlags starts as a copy of the global settings set — note that build flags do not seed it.

  5. Session overrides are applied only if they are newer than the global-settings record:

    const lastChangedInStorage = this.getLastChangedByKey();           // setting's lastChange
    const lastChangedInSession = this.getLastChangeExecFlagsFromSession();
    if (lastChangedInSession > lastChangedInStorage || lastChangedInStorage === 0) {
      availableFlagExecStatus = this.getAvailableFlagsExecStatus();     // use session
    } else {
      availableFlagExecStatus = [];                                    // discard session
    }
  6. applyExecFlags then adds every true override and removes every false one.

  7. The result is one row per flag: { key, build, exec, globalSettings }.

Two non-obvious consequences.

Global settings beat build flags. The exec set is seeded from global settings, so a flag baked into the bundle but absent from FEATURE_FLAGS_IA resolves to off — with build: true and exec: false in the DEV Admin table.

Editing the global setting invalidates session overrides. Writing the setting stamps a new lastChange, which makes every existing session override older and therefore discarded. Someone else’s DEV Admin "save to global settings" silently resets your local toggles.

isEnabled at runtime

isEnabled(feature: FEATURE_FLAGS): boolean {
  const flags = [...this.featureFlags];                     // build flags
  this.applyExecFlags(this.getAvailableFlagsExecStatus(), flags);   // + session overrides
  if (flags.length === 0 || typeof feature !== 'string') return false;
  return flags.findIndex((f) => f === feature) >= 0;
}

isEnabled reads build flags plus session overrides — it does not consult global settings directly. It works because initFF() writes the fully-resolved set (including global settings) into sessionStorage before any isEnabled call, and FeatureFlagsGeneralGuard runs initFF() on every guarded navigation.

The practical implication: isEnabled is only correct after a guarded navigation. Do not call it in a constructor that can run before the first guard — AppComponent does exactly that for CHECK_VPN, which is why the VPN tester can miss its flag on the very first load.

Initialisation

FeatureFlagsGeneralGuard (src/app/guards/feature-flags-general.guard.ts) is on every feature route, second in the guard order:

return this.store.select('auth').pipe(
  filter((data) => data && data.user),          // wait for a logged-in user
  first(),
  switchMap(() => from(this.featureFlagsService.initFF())),
  tap(() => { if (!environment.production) this.displayFeatureFlagsStatus(); }),
  map(() => true),
  catchError(() => of(true)),                   // never block on flag failure
);

Three things to note:

  • It waits for authentication — flags cannot resolve before login, because the global-settings read needs a token.

  • It always resolves true. A flag-service outage degrades to build-flag behaviour rather than locking users out.

  • In non-production it logs the resolved table once per page load:

    [Feature Flags] Current Functionality Status:
    
    SUPPLIER_ACCOUNT_HEALTH: Enabled
    TASKS_MID_VER_SIMPLE_QUERY: Disabled
    …

initFF() itself:

async initFF(): Promise<void> {
  this.featureFlags = this.parseRawFlags(environment.featureFlags);
  const flags = await this.getRecalculatedFlagsStatus();
  this.storeAvailableFlagsExecStatus(flags.map(({ key, exec }) => ({ [key]: exec })));
}

LoginComponent clears session flag state on entry (clearAvailableFlagsExecStatus() and FeatureFlagsEvaService.reset()), so overrides do not survive a logout.

The four ways to apply a flag

1. Service check

if (this.featureFlagsService.isEnabled(FEATURE_FLAGS.DELETE_SUPPLIER)) { … }

For logic. Note the isEnabled caveat above.

2. Structural directive

<div *appEnabledByFeatureFlag="featureFlags.EVENT_RULES">…</div>

EnabledByFeatureFlagDirective wraps NgIf by instantiating it manually:

this.ngIfDirective = new NgIf(this.viewContainer, this.templateRef);
this.ngIfDirective.ngIf = this.featureFlagsService.isEnabled(featureName);

The flag is evaluated once, in the input setter. Toggling a flag in DEV Admin does not re-render already-created views — a reload is required.

3. Route guard

{
  path: 'documentation',
  component: DocumentationComponent,
  canLoad: [FeatureFlagGuard],
  canActivate: [FeatureFlagGuard],
  data: { enabledByFeatureFlag: FEATURE_FLAGS.PYB_DOCUMENTS },
}

FeatureFlagGuard returns true when data or data.enabledByFeatureFlag is absent, and navigates to /404 when the flag is off. Note canLoad has no effect here — nothing is lazily loaded.

FeatureFlagGuard (blocks the route) and FeatureFlagsGeneralGuard (initialises flags, always passes) are different guards with confusingly similar names. Most routes have the latter; only two have the former. A flag applied only through the sidebar directive hides the link but leaves the URL reachable — as is the case for UNDERWRITING_TRACKER.

4. Template directive in the sidebar

<ng-container *appEnabledByFeatureFlag="featureFlags.UNDERWRITING_TRACKER">
  <li *ngxPermissionsExcept="[…]"><a [routerLink]="['/underwriting']">…</a></li>
</ng-container>

Flags and permissions compose; both must pass.

EVA flags

FeatureFlagsEvaService manages EVA’s flags from inside Internal Admin:

  • Separate session keys: FeatureExecFlagsEva, FeatureExecFlagsLastChangeEva.

  • Separate global-settings key: FEATURE_FLAGS_EVA.

  • initFF(availableFlags, buildProvidedFlags) takes EVA’s own available and build sets as arguments — EVA reports them over postMessage (CommunicationService.featureFlagsFromEva$).

  • storeAndSend(flags) persists and pushes the enabled set to EVA via sendUpdatedConfig.

  • An always-on list forces certain flags true:

    private readonly ALWAYS_ON_FLAGS = ['🎪'];

    The single entry is an emoji sentinel, unioned into the fetched set and forced to true in storeAndSend. Intentional, not a stray character.

  • reset() clears the initialized guard — though the early return inside initFF is commented out, so initialisation always re-runs.

Persisting flags

storeState(settingKey, flags) writes the enabled set to global settings:

config.config_value.flags = flags.join(',');
config.config_value.lastChange = +new Date();
return this.globalSettingsService.edit(settingKey, config);

This is an environment-wide change: every user’s next initFF() picks it up, and as noted above it invalidates everyone’s session overrides.

changeFlag(flag, value) is the session-only equivalent — it recalculates the table, flips one exec value and rewrites sessionStorage.

Parsing rules

private parseRawFlags(flagString: string): FEATURE_FLAGS[] {
  if (!flagString) return [];
  let flags = flagString.split(',').map((f) => f.trim()).sort();
  if (flags[0] === '') flags = [];
  return flags as FEATURE_FLAGS[];
}
  • Comma-separated, no spaces (the README’s convention; trim() tolerates them anyway).

  • Sorted, so ordering in the source string is irrelevant.

  • An empty string yields an empty array, and a leading empty element clears the whole list — so ',A,B' resolves to no flags, not two.

  • The cast to FEATURE_FLAGS[] is unchecked: a typo’d flag name in the environment variable silently becomes a flag that nothing reads.

Setting flags per environment

FEATURE_FLAGS is read from the shell at build time by deployment/set-env.ts:

featureFlags: '${process.env.FEATURE_FLAGS ? process.env.FEATURE_FLAGS : ''}',

Neither setup_template-dev.cfg nor setup_template-production.cfg exports it, so build flags are empty unless the pipeline sets FEATURE_FLAGS explicitly. In practice flags are managed through global settings, not the build.

For local work, edit featureFlags in src/environments/environment.ts — remembering that the file is regenerated by setenv.sh. See environments.

Adding a flag

  1. Add a member to FEATURE_FLAGS in feature-flags.const.ts.

  2. Apply it — service check, directive, or FeatureFlagGuard with data.enabledByFeatureFlag.

  3. If it gates a menu item, wrap the item in appEnabledByFeatureFlag *and decide whether the route itself should be blocked with FeatureFlagGuard.

  4. Enable it per environment through DEV Admin (/admin/feature-flags) → save to global settings.

  5. Document it in the flag catalogue.

Gotchas summary

Issue Detail

Global settings beat build flags

The exec set is seeded from global settings.

Editing the setting resets session overrides

Via the lastChange comparison.

isEnabled needs initFF to have run

It reads build flags + session storage only.

The directive evaluates once

No live re-render on toggle.

Two similarly named guards

FeatureFlagGuard blocks; FeatureFlagsGeneralGuard initialises.

Flag typos are silent

The cast to FEATURE_FLAGS[] is unchecked.

Leading comma clears the list

parseRawFlags empties the array if the first element is ''.

Session storage is per tab

Two tabs can disagree.