Skip to content

Managing Feature Flags

This guide explains how to add, modify, and use Boolean feature flags in SyRF, including non-production runtime overrides shared by browser and API consumers.

Overview

The feature flags system uses env-mapping.yaml as the single source of truth for all environment configuration, including feature flags. The generator reads from this unified schema and produces:

  • TypeScript interfaces (FeatureFlags, FeatureFlagsExternalConfig)
  • Default values (FEATURE_FLAG_DEFAULTS)
  • NgRx selectors for each flag
  • Config parser (parseFeatureFlags()) for runtime parsing
  • Updates to env.template.json for NGINX envsubst
  • Updates to Helm values.yaml for deployment configuration

Key benefits:

  • Adding a new feature flag requires only editing env-mapping.yaml and running the generator
  • Uses unified SYRF__ prefix for all environment variables (not legacy SPA__)
  • The API receives the same deployed defaults as the web app, so cross-stack gates such as bulkPdfUpload stay consistent
  • Non-production administrators can temporarily override catalogued Boolean feature flags where runtime changes are supported, without replacing the deployed defaults
┌─────────────────────────────────────────────────────────────┐
│           src/charts/syrf-common/env-mapping.yaml            │
│                  (Single Source of Truth)                    │
│                                                              │
│  featureFlags:                                               │
│    services: [api]  # Shared web/API deployed defaults       │
│    envVars:                                                  │
│      - name: SYRF__FeatureFlags__MyNewFeature                │
│        valuePath: featureFlags.myNewFeature                  │
│        default: "false"                                      │
│        web: { tsName: myNewFeature, tsType: boolean }        │
│        featureFlag: true                                     │
│        displayName: "My new feature"                         │
│        category: uiFeatures                                  │
│        description: "Enable my new feature"                  │
└─────────────────────────────────┬────────────────────────────┘
                                  │
                        pnpm run generate:flags
                                  │
           ┌──────────────────────┼──────────────────────┐
           ▼                      ▼                      ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│  TypeScript Files   │ │  env.template.json  │ │   Helm values.yaml  │
│                     │ │                     │ │                     │
│  - types.ts         │ │  + myNewFeature:    │ │  featureFlags:      │
│  - constants.ts     │ │    "${SYRF__...}"   │ │    myNewFeature:    │
│  - selectors.ts     │ │                     │ │      false          │
│  - parser.ts        │ │                     │ │                     │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘

File Locations

File Purpose
src/charts/syrf-common/env-mapping.yaml Schema definition (edit this!)
src/services/web/scripts/generate-feature-flags.ts Generator script
src/services/web/src/app/generated/feature-flags.types.ts Generated TypeScript interfaces (types only, no JS output)
src/services/web/src/app/generated/feature-flags.constants.ts Generated runtime constants (as const)
src/services/web/src/app/generated/feature-flags.selectors.ts Generated NgRx selectors
src/services/web/src/app/generated/feature-flags.parser.ts Generated config parser
src/services/web/src/assets/data/env.template.json Auto-updated envsubst template
src/services/web/.chart/values.yaml Auto-updated Helm values

Flag lifecycle requirements

A feature flag switches a feature on and off. It does not migrate data. Chris's rule (2026-10-17):

"Flags are used to turn features on and off, not necessarily to auto migrate databases back and forth. So whilst the flag is present and flippable both the database and app code should work well with either on or off state and should be happy going back and forth. That should be an implementation requirement when adding the feature flag in the first place. (The extra infrastructure for and to support a specific flag can be removed at a later stage when the flag is retired.)"

So while a flag exists, the code and the database must work with it on or off, and survive being switched back and forth any number of times, including with data created in the other state. The author of the flag builds and tests that when the flag is added; it is not left to be discovered during a rollback.

This section is the canonical text. The agent checklist in .claude/rules/feature-flags.md summarises it, and ADR-023 records the migration side. If they differ, this section wins.

Environments, deployments, migrations and flags

Four terms have been getting confused. Use them in this sense in pull requests, runbooks and ADRs.

  • Environment: local, preview, staging or production. Each has its own database and its own flag values.
  • Deployment: puts a build into an environment. A promotion deploys staging's build to production; a rollback deploys the previous build. Flag values are configuration: changing one is a separate decision from deploying a build, even when a deployment-managed flag needs a rollout to change.
  • Migration: part of a build. It runs before that build is deployed (ADR-023). While the previous build may still be serving, a migration only adds things, and it must suit every flag state of both builds. Flags never run, skip or reverse a migration, and no migration reads a flag.
  • Flag: a switch that a running build reads, per environment. The only allowed link with migrations is one-way: a feature may refuse to switch on, failing closed, until its migrations have run (its readiness check).

For example, deletionLifecycle needs the deletion indexes, which are migrations 0005 to 0008 (ADR-024). With the flag on but those migrations missing, the search-delete routes answer a typed 503 instead of recording a deletion that nothing could advance. That is the allowed direction: the missing migration keeps the feature closed. Turning the flag on or off never applies or reverses those migrations, and applying them does not turn the flag on.

Requirements when a flag is added

  1. Both states work. On is the new behaviour. Off is the previous behaviour, or the feature shown as unavailable; never broken. Off is not only a rollback state: for a flag that production has not turned on, it is production's behaviour, which is what keeps main promotable.
  2. Round trips are safe, any number of times. Data created while the flag is on is handled correctly while it is off (kept, ignored, or finished by work that is not flag-gated), and the reverse. Work already accepted keeps converging after the flag goes off: a kill switch stops new exposure, it does not strand what was already started.
  3. Flags read by several services. The browser, the API and Project Management may read the same flag, and a rollout reaches them at different times. Every combination of per-service values that a rollout can produce is safe, or the feature refuses to start until the values agree. The dependency catalog (see Non-Production Runtime Overrides) is one way to enforce this.
  4. Tests. An on, then off, then on test, with data created in each state.
  5. Retirement. The dead path is deleted. Anything that existed only for the flag (code, collections, indexes, data) is removed in a later clean-up, by a migration if indexes or data are involved, once no deployable build and no flag state needs it. Until then it stays, so that switching the flag back is always possible.

The pull request template has a checkbox for requirements 1, 2 and 4.

Adding a New Feature Flag

Adding a flag includes meeting the flag lifecycle requirements: both states work, round trips are safe and the on, off, on test exists. The steps below cover only the wiring.

Step 1: Edit the Schema

Add your flag to src/charts/syrf-common/env-mapping.yaml in the featureFlags section:

featureFlags:
  services: [api]  # Shared web/API deployed defaults
  displayName: "Feature Flags"
  envVars:
    # ... existing flags ...

    - name: SYRF__FeatureFlags__MyNewFeature
      valuePath: featureFlags.myNewFeature
      default: "false"
      web:
        tsName: myNewFeature
        tsType: boolean
      featureFlag: true
      displayName: "My new feature"
      category: uiFeatures
      description: "Enable my new feature"

displayName is required for every featureFlag: true entry: write a short sentence-case label. The generator emits it as FEATURE_FLAG_DISPLAY_NAMES in feature-flags.constants.ts, and the admin runtime feature flags page (/admin/feature-flags) shows it as the row name. Generation fails with the list of offending entries if any feature flag lacks a displayName.

Step 2: Run the Generator

cd src/services/web
pnpm run generate:flags

Output:

Feature Flag Generator
======================
Schema: /home/.../feature-flags.schema.yaml

Loaded 31 feature flags

Categories:
  - Project Settings: 10 flags
  - Ui Features: 12 flags
  - Rob Features: 3 flags
  - Data Features: 1 flags
  - Development: 3 flags
  - Monitoring: 3 flags

Generated: src/app/generated/feature-flags.types.ts
Generated: src/app/generated/feature-flags.selectors.ts
Generated: src/app/generated/feature-flags.parser.ts

Updating env.template.json...
  + myNewFeature: "${SPA__MyNewFeature}"

Updating values.yaml...
  featureFlags section updated

Generation complete!

Also add the Boolean flag to the explicit allow-list in src/services/api/SyRF.API.Endpoint/RuntimeFeatureFlags/RuntimeFeatureFlagCatalog.cs. The catalog test compares this allow-list with env-mapping.yaml, including each .NET configuration-property name, so a missing or mismatched entry fails validation. Do not add arbitrary Boolean operational or security settings to this catalog.

Step 3: Use the Flag in Components

Import and use the generated selector:

import { selectMyNewFeature } from '@app/generated/feature-flags.selectors';

@Component({
  selector: 'app-my-component',
  template: `
    @if (myNewFeatureEnabled()) {
      <div>New feature content!</div>
    }
  `
})
export class MyComponent {
  private store = inject(Store);

  // Using signals (recommended)
  myNewFeatureEnabled = this.store.selectSignal(selectMyNewFeature);

  // Or using observables
  myNewFeature$ = this.store.select(selectMyNewFeature);
}

Step 4: Configure for Deployment

The Helm values.yaml is auto-updated. To enable in a specific environment, update cluster-gitops:

# cluster-gitops/syrf/environments/staging/services/web.yaml
featureFlags:
  myNewFeature: true  # Enable in staging

Env var precedence and boolean defaults

The chart renders one env entry per name. Two rules keep that true:

  • Explicit env: overrides generated flag env vars. If an environment's raw env: map (in cluster-gitops) sets a name that the generated block also emits, for example SYRF__FeatureFlags__BulkStudyUpdateRequireCurrentExecutionVersion: "true", the generated entry is skipped and only the explicit value is rendered. This is done in syrf-common.deployment.dotnet (syrf-common.env.withoutExplicit), so it applies to api, project-management, quartz and identity. Prefer the flag's Helm value (featureFlags.<name>) over a raw env: entry; the override exists so a duplicate key can never be rendered, not as the normal way to set a flag.
  • An explicit false is honoured when the default is "true". Helm's default treats false as empty, so a plain | default "true" can never render false. For any envVars entry whose default: "true", the generator emits include "syrf-common.boolOrDefault", which keeps an explicit bool and falls back to the default only when the value is unset. Do not hand-write | default "true" on a boolean in a chart template.

A chart value that has no env-mapping.yaml entry renders nothing. Do not add one to a service chart's values.yaml as documentation; an application setting that is read from appsettings (for example FeatureFlags:MaxInProgressSessions) is not configured by the chart.

Carrying a web flag to Project Management

The featureFlags section renders only into the API. When Project Management also reads a web flag, declare the same variable and valuePath in the backendFeatureFlags section too (and keep the web: block only on the featureFlags entry), as bulkPdfUpload and reviewEligibilityPolicy do. One featureFlags.<name> Helm value then drives the browser, the API and Project Management. A flag that is read by Project Management but declared only in featureFlags is silently off there (#4292).

Project Management does not read the API's runtime override store, so a runtime override changes the API and the browser but never Project Management. A flag read by both services must therefore be deployment-managed: the API pins its effective value to the deployed value, rejects a runtime change with 400 Bad Request and shows it as deployment-managed on the Feature flags admin page. An override saved before a flag was pinned has no effect and can still be cleared. reviewEligibilityPolicy is deployment-managed for this reason (it keeps its pageReload activation, which now only describes how a changed deployed value reaches an open browser). Change it in GitOps for every consuming service; see PinnedKeys in ProjectStatisticsRuntimeActivation.

Carrying a web flag to Identity

Do not add identity to the featureFlags section's services: that would render every catalogued web flag into the Identity pod. When Identity needs one web flag, add a narrow Identity-only section that maps the same Helm value, as identityThemeFlags does for themeToggle (account portal slice 3, shared theme preference):

identityThemeFlags:
  services: [identity]
  envVars:
    - name: SYRF__FeatureFlags__ThemeToggle
      valuePath: featureFlags.themeToggle
      default: "false"

Identity binds it on IdentityFeatureFlags at start-up and never reads the API's runtime override store, so the Identity half is deployment-only: a runtime override on the Feature flags admin page changes the app, not Identity. Set the value in the Identity service's environment values in cluster-gitops as well as the web and API values.

Non-Production Runtime Overrides

The deployed GitOps value remains the default and recovery fallback. In staging and preview environments, a real SyRF administrator can set or clear an override through the API:

PUT /api/runtime-feature-flags/bulkPdfUpload
Content-Type: application/json

{
  "value": true,
  "expectedRevision": 12,
  "reason": "Exercise bulk PDF upload in preview 2943"
}

Use "value": null to clear an override and return to the deployed value. GET /api/runtime-feature-flags returns the full authoritative snapshot, revision, deployed and override values, and activation metadata. Mutation is disabled in production.

The Feature groups section offers bulk PDF upload, reviewer workspace and supported statistics application-use controls, with separate configured and effective On/Off/Mixed states. Group membership comes from the API snapshot. Enable/Disable group writes all members and its audit entry atomically at the current revision; additional dependencies still require confirmation. Individual flags remain available below. Statistics calculation/maintenance, project readiness and explicit initial builds remain in the statistics operations workflow. A failed or timed-out response may have committed: the UI refreshes without replaying it.

Use precomputed screening statistics (statistics-consumers-v2) includes serving, pages, Project Overview, screening history and SignalR statistics refreshes. Enabling requires deployed serving, maintenance and screening; live refreshes also need SignalR connectivity. Existing per-project enrollment, freshness and fallback still apply. Disabling stops these consumers while calculation/maintenance stays on. The exports flag has no implemented export/report consumer, so it is excluded. Stage Overview and stage history remain individual because staging has annotation maintenance disabled. Reviewer history remains individual because it requires membership-screening maintenance, which is also disabled on staging. Maintenance and the seven metric-family gates remain deployment-managed across write hosts and are not group members. The old statistics-screening-v1 and statistics-consumers-v1 requests remain accepted with their original members for already-open clients; new snapshots advertise only v2.

The Bulk PDF upload and processing activity preset (bulk-pdf-upload-v1) changes bulkPdfUpload and studyManagementProcessing together. The Processing activity page also shows other study jobs; use the individual flags to control these separately. Deletion handling in API and Project Management, notifier cleanup authority and a ready PDF processing pipeline remain deployment prerequisites. Missing deletion handling blocks the whole group change. Without notifier authority, a saved upload override remains effectively off in the browser. Follow the notifier authority activation runbook before enabling uploads. Disabling this preset hides upload and Processing entry points; it does not cancel existing jobs or disable cleanup authority. The notifier cleanup authority (bulkPdfNotifierAuthority.enabled, deployment-managed) follows the same lifecycle rule: off pauses clean-up and refuses deletes with nothing lost, and switching it back on is checked against the recorded receipt's binding to its environment root and cleanup authority (ADR-017 amendment 2026-10-17).

In the admin page, On, Off, and Default submit immediately. Expand Optional change comment beside a flag before clicking to include a comment in that change's audit entry. Without a comment, the action is recorded automatically. Comments are scoped to their row, cleared after success, and retained after failure. A dependency change still opens a confirmation describing the exact additional changes; cancelling that dialog applies nothing. A revision conflict reloads current values and requires a fresh action rather than silently overwriting another administrator.

Deployed is the environment's baseline, Override is the saved replacement, and Effective includes dependency normalization and domain constraints. These overrides are environment-wide, not project enrollment or permission grants. Statistics maintenance and family gates are shown as deployment-managed instead of offering working-looking runtime buttons. An old override can still be cleared to the deployed default. Global statistics serving cannot be enabled unless deployed serving is already enabled. Use the statistics pilot administration workflow for project readiness, builds and consumer presets.

If a change needs other runtime-manageable flags, the first PUT returns 422 Unprocessable Entity with code: "featureFlagDependenciesRequired", the dependency set, and canApplyAutomatically: true. After showing that set to the administrator, repeat the PUT with the exact "confirmedDependencies" key/value set and the latest snapshot revision. Keep the legacy "includeDependencies" field false: an older API replica will then reject the retry instead of applying dependencies it cannot bind to that exact plan. The current server rejects the retry if another API generation resolves a different dependency plan, rather than applying changes the administrator did not review. It then applies the requested flag and every confirmed dependent change atomically in one revision and audit entry.

The snapshot's effective values are authoritative even when a flag has no override. Loaded flags with unsatisfied prerequisites are normalized Off in both API and browser. If a later mutation would reactivate another dormant configured flag without confirming it, that flag is pinned Off by an audited override and must be enabled explicitly in a separate reviewed change.

Deployment-managed dependencies are different: their 422 response has canApplyAutomatically: false and cannot be bypassed with includeDependencies. For example, bulkPdfUpload requires DeletionLifecycle=true in both API and Project Management. Roll out and verify that shared GitOps value across every consuming service before enabling the runtime override. During a rolling deployment, wait until all relevant workloads are healthy on the new generation; the API's local readiness check is deliberately only a fail-closed final guard, not proof that a different service has completed its rollout.

Each change atomically updates the normal current-state override document and appends a separate audit entry. Authorization always checks the real signed-in user's administrator group, even while that administrator is impersonating someone else. The audit entry records both the real and impersonated identities plus the required reason.

After a successful mutation, the API broadcasts only the new revision over SignalR. Browsers then fetch a complete snapshot; they also poll every five minutes as a slow fallback. The API refreshes its cached snapshot periodically so backend gates converge across pods. bulkPdfUpload is applied to both the Angular gate and the API endpoint gate from that same snapshot.

Runtime-manageable Boolean flags use the same endpoint. Most changes apply immediately after the next snapshot refresh. The snapshot marks integratedPdfViewer, signalRActive, zonelessChangeDetection, errorTracking and logRocketEnabled with activationRequirement: "pageReload" because their viewer, transport, change-detection, diagnostic, telemetry or session-recording lifecycles are bound during page initialization. Their overrides are persisted immediately and become active when the browser is reloaded; the initial snapshot is applied before those lifecycle services start. reviewEligibilityPolicy is also marked pageReload (review configuration screens read the eligibility mode when they load) but it is not runtime-manageable: it is deployment-managed because Project Management reads it too (see Carrying a web flag to Project Management). It keeps its ReloadRequired(...) catalog entry because a changed deployed value still reaches an open browser only on reload. The authoritative list is ReloadRequiredFlags in src/services/api/SyRF.API.Endpoint.Tests/RuntimeFeatureFlagCatalogTests.cs, which pins every ReloadRequired(...) entry in the catalog. GitOps remains the deployed default and production source of truth.

See the administration delivery slices for current propagation boundaries, proposed grouped controls and retirement review criteria.

This MVP deliberately does not include percentage or audience targeting, typed non-Boolean configuration, event sourcing, projections, replay, or a richer audit-history UI.

Production runtime policy, release records, targeting and announcements are planned, not implemented: see the runtime feature releases plan and ADR-025 (Proposed). This guide describes current behaviour only.

Schema Properties Reference

Property Required Description
name Yes Environment variable name (SYRF__FeatureFlags__*)
valuePath Yes Path in Helm values.yaml (e.g., featureFlags.myNewFeature)
default Yes Default value as string ("true" or "false")
web.tsName Yes camelCase name used in TypeScript (e.g., myNewFeature)
web.tsType Yes TypeScript type (always boolean for feature flags)
featureFlag Yes Must be true to mark as feature flag
displayName Yes Sentence-case label shown on the admin runtime feature flags page (generation fails without it)
description Yes Human-readable description (used in JSDoc comments)
category Yes Grouping for organization (see categories below)
adminOnly No If true, flag requires admin UI to be visible

Categories

Use existing categories for consistency:

Category Purpose
projectSettings Project configuration features
uiFeatures General UI features
robFeatures Risk of Bias related features
dataFeatures Data import/export features
development Development/debugging features
monitoring Observability features (Sentry, LogRocket)

Admin-Only Flags

Flags marked adminOnly: true are automatically gated by the admin UI state:

- name: SYRF__FeatureFlags__ExampleAdminAction
  valuePath: featureFlags.exampleAdminAction
  default: "false"
  web:
    tsName: exampleAdminAction
    tsType: boolean
  featureFlag: true
  category: robFeatures
  description: "Example admin action (illustrative, not a deployed flag)"
  adminOnly: true

Generated selector automatically combines with admin state:

export const selectExampleAdminAction = createSelector(
  selectConfigState,
  selectShowAdminUiOptions,
  (configState: AppConfig, showAdminUi: boolean): boolean =>
    configState.exampleAdminAction && showAdminUi,
);

Generator Commands

cd src/services/web

# Generate all files
pnpm run generate:flags

# Validate schema without writing (CI/pre-commit friendly)
pnpm run validate:flags

# Preview changes without writing files
pnpm run generate:flags -- --dry-run

# Show verbose output including Helm env vars
pnpm run generate:flags -- --verbose

Pre-commit Hooks

Two pre-commit hooks validate generated code when relevant files change:

1. validate-env-blocks (syrf-common)

Triggered by changes to:

  • src/charts/syrf-common/env-mapping.yaml
  • src/charts/syrf-common/templates/_env-blocks.tpl

2. validate-feature-flags (web)

Triggered by changes to:

  • src/charts/syrf-common/env-mapping.yaml
  • src/services/web/src/app/generated/feature-flags.*.ts
  • src/services/web/src/assets/data/env.template.json
  • src/services/web/.chart/values.yaml

If validation fails (files are stale), the commit is blocked until you run the appropriate generator.

CI Validation

The validate-generated-code job in pr-tests.yml runs both validators:

  1. Detects if schema or generated files changed
  2. Runs pnpm run validate:env-blocks in syrf-common
  3. Runs pnpm run validate:flags in web
  4. Reports status in PR test summary

Why validate-only (not auto-generate)? Generated TypeScript could break the build. Developers should run the generator, review changes, test the build passes, then commit.

Note: The web chart's deployment.yaml env vars are generated by syrf-common/scripts/generate-env-blocks.ts, not the web generator. The web chart uses shared templates via {{ include "syrf-common.env.webConfig" . }} and {{ include "syrf-common.env.featureFlags" . }}.

The generators are idempotent - running them multiple times produces identical output. This ensures consistent validation in CI and pre-commit hooks.

How It Works at Runtime

  1. Build time: Helm templates reference values.yaml paths
  2. Container startup: NGINX envsubst replaces ${SPA__*} placeholders in env.template.json
  3. App bootstrap: main.ts fetches config JSON files
  4. Config parsing: parseFeatureFlags() converts string values to booleans
  5. State management: NgRx store holds parsed config
  6. Component access: Selectors provide typed access to flags
Helm values.yaml → deployment.yaml env vars
                          ↓
                   Docker container
                          ↓
           NGINX envsubst → appConfig.env.json
                          ↓
                main.ts fetch()
                          ↓
           parseFeatureFlags() → AppConfig
                          ↓
              NgRx Store (config state)
                          ↓
              selectMyFeature selector
                          ↓
               Component signal/observable

Architecture: AppConfigService Integration

The AppConfigService cleanly separates generated code (feature flags) from manual code (derived values):

// app-config.service.ts

// AppConfig = FeatureFlags (generated) + AppSettings (manual)
export interface AppConfig extends FeatureFlags, AppSettings {}

// ExternalConfig = FeatureFlagsExternalConfig (generated) + ExternalConfigSettings (manual)
export interface ExternalConfig extends FeatureFlagsExternalConfig, ExternalConfigSettings {}

static loadConfig(externalConfig: ExternalConfig): void {
  // Generated: parses all feature flags using schema defaults
  const featureFlags = parseFeatureFlags(externalConfig);

  // Manual: complex derived values (URLs, version strings, etc.)
  const appSettings = buildAppSettings(externalConfig);

  this.staticConfig = { ...featureFlags, ...appSettings };
}

Why Some Values Aren't Generated

Non-feature-flag configuration (AppSettings) requires manual logic that can't be generated:

Property Reason
displayVersion Environment-specific formatting (30+ lines of logic)
auth0Domain Computed from {tenant}.{region}.auth0.com
apiUrl Derived from apiOrigin + '/api'
protectedUrls String split + array append logic
randomId Runtime-generated browser session ID

File Structure

src/app/
├── generated/                      # AUTO-GENERATED (don't edit!)
│   ├── feature-flags.types.ts      # Interfaces only (no JS output)
│   ├── feature-flags.constants.ts  # FEATURE_FLAG_DEFAULTS, env vars, helm paths
│   ├── feature-flags.selectors.ts  # NgRx selectors
│   └── feature-flags.parser.ts     # parseFeatureFlags()
│
└── core/services/config/
    ├── app-config.service.ts       # Main service (uses generated code)
    └── app-settings.interface.ts   # Non-feature-flag types (manual)

Note: types.ts contains only TypeScript interfaces (erased at compile time). All runtime values (as const) are in constants.ts for clear separation.

Troubleshooting

Generated files out of sync

pnpm run generate:flags
git diff  # Review changes
pnpm run build  # Ensure TypeScript compiles

Validation fails in CI

The validate-generated-code job reports which files are stale:

# Run locally to see the same output
cd src/services/web
pnpm run validate:flags

This checks if env.template.json, values.yaml, and TypeScript files match the schema.

Pre-commit hook fails

# Run the generator to update files
pnpm run generate:flags

# Check what changed
git diff

# If TypeScript changed, test the build
pnpm run build

# Then stage the generated files
git add src/app/generated/ src/assets/data/env.template.json .chart/values.yaml

Flag not working in deployment

  1. Check Helm values in cluster-gitops
  2. Verify deployment has correct env var: kubectl exec ... -- env | grep SYRF__FeatureFlags
  3. Check browser dev tools → Network → fetch for config JSON
  4. Verify flag value in parsed config (browser console)

TypeScript build fails after generation

Generated code might reference types that don't exist yet. Check:

  1. Is selectConfigState exported from the config selectors?
  2. Is selectShowAdminUiOptions available for admin-only flags?
  3. Are NgRx imports correct in the generated selectors file?

Shared reader: first API/PM migration slice

The shared reader is an opt-in non-production migration seam. Its independent deployment setting is RuntimeFeatureFlags:SharedReaderMode (SYRF__RuntimeFeatureFlags__SharedReaderMode in the host environment). Configure it through the existing environment/GitOps configuration; it is deliberately not a runtime flag that could disable its own provider. The default is Disabled, preserving existing behavior.

Mode API Project Management
Disabled Existing runtime provider and behavior Existing startup/domain behavior
Shadow Observe persisted global overrides without changing consumers Observe the same persisted overrides without changing domain behavior
Membership Use the shared snapshot for the existing disableMembership action-selector gate Continue shadow observation; no membership or reviewer domain control is changed
StatisticsServing Invalid for this host Use the shared materializedProjectStatisticsServing value as an additional stop for precomputed statistics serving

StatisticsServing also uses shared overrides for the Pages, Stage Overview, SignalR and Exports consumer selectors. These are reversible consumer requests, not evidence that any project is Fresh or allowlisted. disableMembership gates the auditable disable endpoint; re-enabling an already-disabled member stays available with the flag off. It does not remove membership authorization or rewrite persisted membership state. Each route decision captures one immutable observation. Already admitted requests complete under the existing application protocol. All other API consumers continue using their existing provider. In StatisticsServing mode PM keeps writes, family gates, project allowlists and reviewer-capacity controls deployment-owned. Shared serving and consumer values can stop precomputed reads, but cannot enable them when deployment serving or writes are off. The durable statistics control and freshness checks still apply. An unavailable or stale observation stops new materialized reads in PM; it does not stop maintenance or undo an admitted operation. This makes the existing Use precomputed statistics admin switch meaningful in both API and PM on an enrolled non-production deployment. Calculate and maintain statistics is still a separate, deployment-bound control; its coordinated runtime transition remains follow-up work.

Both hosts reconcile the existing global Mongo override document every 15 seconds with majority reads and a five-second read deadline. There are no change streams in this first slice. Complete frozen snapshots replace previous snapshots, so resetting a flag removes the old override. Missing/malformed documents, read failure, revision regression, conflicting content at an equal revision and observations older than 60 seconds produce an unknown/unavailable decision. The enrolled API route fails closed in those states; the last observation remains available for diagnosis. A successful later reconciliation recovers. Cold start with no persisted state does not synthesize an authoritative revision zero: first initialize the legacy state through an ordinary reviewed flag edit while in Shadow mode, then compare before selecting Membership.

The admin flag page exposes GET /api/runtime-feature-flags/provider-status to real administrators only. It distinguishes the independently fetched saved revision from the local observed revision, mode, state, observation time and nullable evaluated value. Current means a recent successful local observation, not fleet convergence. PM also exposes a limited read-only GET /api/runtime-feature-flags/reader-status containing only local mode, observation state/revision/time and the effective statistics-serving decision; the same Boolean policy is already public in the API snapshot. The admin page displays that result separately from the API result. PM retains the runtime-feature-flag-reader diagnostic health entry. Neither endpoint is a fleet acknowledgment. There is no fleet registry, and the UI explicitly reports fleet state as unknown.

The legacy-global-overrides digest covers only sorted override content. It excludes deployed defaults, catalog metadata and environment identity. Matching digests do not establish equal effective values when hosts have different defaults. PM health includes its selected consumer's deployed default and evaluated value. This is not the approved-baseline/epoch authority described by the broader architecture; a document carrying the future storeEpoch field is rejected by this legacy reader until its migration is implemented. Production and unknown/conflicting deployment identities reject opt-in modes at startup.

Before rollback to Disabled, compare the saved revision, the legacy provider's observed value and the shared reader's value for disableMembership; wait for legacy convergence or write a reviewed forward revision first. Do not use rollback to bypass an unavailable authority or restore a stopped capability. For PM StatisticsServing, first make the deployment fallback agree with the reviewed runtime serving decision. Otherwise reverting the reader to Disabled can reopen precomputed serving despite an Off override. No deployment setting or runtime policy is changed merely by merging this implementation.

Consumer inventory guard

pnpm run validate:generated also checks the C# typed consumer inventory in consumer-manifest.json. New or changed common flag seams and reads through typed/inferred aliases require a reviewed classification, owner, scope and activation boundary. Changes to _flags.SomeGate in an already classified file are detected. This guard is conservative static evidence, not proof of every dynamic configuration lookup or external worker; those remain explicit audit work before migration. Update the manifest after reviewing a change, never regenerate it in CI to accept a new read automatically. In PR Tests, the Validate Generated Code lane runs for any change to the manifest, the validate:generated scripts or a .cs file under src/libs/ or src/services/, so a changed flag read cannot merge unclassified.