Designing a Scalable Flag Taxonomy

This guide is part of the Feature Flag Architecture & Lifecycle Management series. Without a deliberate taxonomy, a flag system degrades quickly: keys accumulate with no owner, targeting rules clash across services, and cleanup becomes archaeology. A taxonomy is the schema and governance layer that keeps every flag findable, attributable, and disposable — at any scale.

This guide covers the key schema, required metadata fields, lifecycle states, and the CI enforcement that makes the rules stick. It does not cover SDK integration patterns (see server-side SDK integration) or the transport layer that delivers updates to replicas.

The distinction worth internalising up front is that a taxonomy is not a naming convention with better marketing. A naming convention tells you how to spell a key; a taxonomy tells you who owns it, when it dies, what class of behaviour it belongs to, and which automated system is responsible for each of those facts. The spelling is the cheapest part — you can regex it in an afternoon. The expensive, durable value is the metadata contract and the machinery that enforces it on every merge, because that is what survives reorgs, team handoffs, and the departure of the one engineer who remembered why checkout.payments.express-pay existed. Treat every rule below as something a machine checks, not something a wiki page requests, and the taxonomy will still be intact two years from now when the humans who wrote it have all moved on.

Flag taxonomy metadata schema A flag record with three key segments — namespace, service, and feature — and mandatory metadata fields for owner, expiry, lifecycle state, and flag type. Flag key checkout payments express-pay namespace service feature Required metadata owner payments-team type release created 2026-06-01 expiry 2026-08-01 state active defaultVariant off Lifecycle states draft active deprecated archived schema valid rollout live code removed flag deleted
A flag key encodes three segments; each flag record carries mandatory metadata that drives ownership, expiry enforcement, and lifecycle progression.

Problem Framing

Growing engineering organizations accumulate flags the same way they accumulate dead code: faster than anyone cleans them up. A service with no naming standard ends up with keys like enable_new_checkout, newCheckoutFlow, and USE_CHECKOUT_V2 all in flight simultaneously — owned by no one, never removed, each one a trap for the next engineer who reads the codebase. Ownership disputes delay cleanup; missing expiry dates mean no automated system can ever know a flag is safe to remove; duplicate keys across microservices cause targeting logic in one team’s flags to shadow another’s.

The cost is not hypothetical, and it compounds non-linearly. At 50 flags a spreadsheet works; at 500 the spreadsheet is stale the day it is written; at 5,000 — a number a mid-sized company reaches within a couple of years of adopting flags enthusiastically — no human can hold the inventory in their head, and every stale toggle is a live branch in production that a reviewer must reason about. Each of those branches doubles the notional state space of the code path it guards, so a checkout service carrying twelve forgotten flags has, on paper, up to four thousand distinct behaviours nobody has tested as a set. The failure mode that finally forces the issue is almost always the same: an incident where the responder greps the flag registry for the service that is on fire and finds three plausible kill-switch candidates, none of them documented, and has to guess which one actually severs the failing dependency under time pressure. A taxonomy is the thing that makes that grep return exactly one answer with an owner attached.

This guide does not cover progressive delivery workflows, SDK initialization, or cache topology — it focuses exclusively on what a flag record is and the rules that govern it.

How flag sprawl compounds without a taxonomy Three inconsistent keys for the same feature lead to no clear owner, which means no automated system can retire them, so they accumulate as traps for the next engineer. 3 keys, 1 feature enable_new_checkout newCheckoutFlow USE_CHECKOUT_V2 no clear owner nobody's problem never retired a trap for the next reader a taxonomy breaks this chain at the first link — one canonical, owned key
Inconsistent keys cascade into unowned, unretirable flags; the taxonomy stops the cascade by making one canonical, attributable key the only valid form.

Prerequisites

Prerequisites for a governed taxonomy Four prerequisites: a central flag registry, JSON Schema validation in CI, a committed service-ownership file, and provider support for the metadata fields. Central registry flagd / vendor Schema in CI ajv-cli Ownership file CODEOWNERS Metadata support owner / expiry
Governance needs a place to store metadata, a gate to enforce it, and an ownership map to attribute it — these four supply all three.

Core Concept & Architecture

The namespace.service.feature Key Schema

A three-segment key is the minimum viable structure. Each segment answers a distinct question:

The full key checkout.payments.express-pay is unambiguous at a glance. The namespace prefix routes it to the right team in any registry query; the service segment enforces ownership; the feature segment describes intent without abbreviation.

Three segments is deliberate, not arbitrary. Two segments (service.feature) collapses the moment two product domains both run a service called search — the ecommerce catalog search and the internal admin search now collide, and you are back to prefixing by hand. Four or more segments tempts teams to encode variant information (checkout.payments.express-pay.v2.enabled) that belongs in the flag’s variants block, not its key, and every extra dot is another place for a typo the linter cannot distinguish from intent. Keep the key describing what is gated and by whom, and push everything about how it is gated into metadata and targeting. The kebab-case rule on the feature segment matters more than it looks: keys end up in log lines, dashboard queries, and shell one-liners, and a mix of expressPay, express_pay, and express-pay for the same concept quietly defeats every grep and every GROUP BY you will ever run against them.

A useful discipline is to make the feature segment read as a noun phrase describing the capability, never as a boolean or an imperative. express-pay is good; enable-express-pay is bad, because the flag being off does not mean “enable-express-pay is false” in any sentence a human would say aloud — the enable prefix double-negates the moment you disable the flag. Reserve the polarity for the variant values, where on/off carries it cleanly.

Reserved prefixes impose global semantics without bespoke tooling:

Prefix Meaning Example
kill. Emergency kill-switch; streaming transport required kill.payments.express-pay
exp. Experiment / A/B test; has analysis window metadata exp.checkout.one-click-upsell
ops. Operational toggle; indefinite lifetime permitted ops.infra.maintenance-mode
(none) Standard release flag; max 90-day TTL checkout.payments.express-pay

See naming conventions for feature flag keys for the regex lint rule and CI enforcement steps.

Metadata Schema

Every flag record must carry five mandatory fields. Optional fields extend it for compliance:

# flagd-format definition with full taxonomy metadata
flags:
  checkout.payments.express-pay:
    state: ENABLED
    variants:
      "on": true
      "off": false
    defaultVariant: "off"       # the safe fallback — used by kill-switch runbooks
    targeting:
      if:
        - { "==": [ { var: "tenantTier" }, "enterprise" ] }
        - "on"
        - "off"
    # taxonomy metadata (stored in a sidecar or provider custom fields)
    metadata:
      owner: "payments-team"          # team, not individual
      type: "release"                 # release | experiment | ops | kill
      created: "2026-06-01"
      expiry: "2026-08-01"            # hard deadline; CI blocks past-expiry flags
      state: "active"                 # draft | active | deprecated | archived
      ticket: "PAY-1234"              # links flag to the work item

The defaultVariant field is load-bearing: it is the variant an emergency kill-switch forces when an incident requires instant rollback, so it must always be the safe state. It is also the variant every SDK returns when the flag store is unreachable, the targeting rule throws, or the context is missing an attribute the rule depends on — which means defaultVariant is simultaneously your incident lever and your degradation behaviour. Get it wrong and a network blip between your service and the flag provider silently turns a feature on for everyone instead of failing closed. The rule of thumb: defaultVariant should point at the pre-existing, already-shipped behaviour, so that “the flag system is down” and “the flag is off” produce identical, boring outcomes.

Two subtleties in the field set are worth calling out. First, owner is a team, never an individual — individuals change teams, take leave, and leave the company, and a flag whose owner is jsmith is orphaned the day that person’s laptop is wiped. Point owner at a group that maps to a rotation or a CODEOWNERS entry, so the on-call for that group inherits the flag automatically. Second, created and expiry should both be machine-set where possible: created stamped by the CI job that first admits the flag, and expiry defaulted to created + default_max_ttl_days unless the author overrides it. Humans are reliably optimistic about how long a rollout will take, so a defaulted expiry that they must extend deliberately produces far cleaner data than a blank field they must fill in thoughtfully.

Lifecycle States

Flags move through four states; transitions must be automated, not manual:

  1. draft — the schema is valid but the flag is not yet live; used during code review and staging validation.
  2. active — the flag is live in production; targeting rules are evaluated on every matching request.
  3. deprecated — the feature code has shipped unconditionally and the flag is pending removal; new evaluations are logged as warnings. This is the entry point for automated cleanup workflows.
  4. archived — the flag definition is retained for audit history but the evaluation engine ignores it.

An audit trail entry should be written on every state transition, recording the actor, timestamp, and reason.

The transitions are intentionally a one-way ratchet: draft → active → deprecated → archived, with no legal backward edge. If a deprecated flag turns out to still be needed, you do not resurrect it to active — you create a fresh flag with a new key and a new expiry, because the moment you allow backward transitions you lose the ability to reason about “deprecated means the code is on its way out.” The one exception teams reach for is rolling active back to draft during a botched launch; resist it. Keep the flag active and set its variant to the safe state instead, so the audit trail records a real production event rather than pretending the launch never happened. The separation between deprecated and archived also does concrete work: deprecated still occupies a key in the registry so a stray in-code reference resolves (to a logged warning and the default variant) instead of throwing, whereas archived frees the key for reuse only after you have proven no code path still evaluates it. Skipping straight from active to archived is the classic way to ship a flag not found exception to production on a Friday.

Reserved prefixes attach global semantics to a key The kill prefix forces streaming transport and no TTL; exp requires analysis-window metadata; ops permits an indefinite lifetime; a bare key is a standard release flag with a 90-day TTL ceiling. kill. streaming transport, no TTL exp. requires analysis-window metadata ops. indefinite lifetime permitted (none) release flag, max 90-day TTL draft → active → deprecated → archived · each transition audit-logged
A prefix imposes global behaviour without bespoke tooling; the same record then progresses through four automated lifecycle states, each logged.

Step-by-Step Implementation

The four steps build the enforcement pipeline from the key outward: publish and lint the schema, validate mandatory metadata, automate expiry-driven state transitions, and gate namespace ownership at the CI boundary.

The taxonomy enforcement pipeline Lint the key schema, validate mandatory metadata, automate expiry-driven state transitions, and gate namespace ownership — each a CI or scheduled check. 1 · Lint keys regex in CI 2 · Validate meta JSON Schema 3 · Auto-expire nightly job 4 · Gate ownership namespace check
Steps 1 and 2 gate every merge; step 3 runs on a schedule; step 4 keeps teams inside their own namespaces.

Step 1 — Define the key schema and publish it

Write the naming standard in a single authoritative document and enforce it with a lint rule before any code review conversation is needed.

# .flaglint.yaml — add this to the repo root
key_pattern: '^(kill|exp|ops|[a-z][a-z0-9]*)(\.[a-z][a-z0-9-]*)(\.[a-z][a-z0-9-]*)$'
max_segments: 3
segment_case: kebab
reserved_prefixes:
  kill: { transport: streaming, max_ttl_days: null }
  exp:  { requires_fields: [analysis_window, hypothesis] }
  ops:  { max_ttl_days: null }
default_max_ttl_days: 90
# CI step: lint all flag keys before merge
npx flaglint --config .flaglint.yaml ./flags/**/*.yaml \
  || { echo "Flag key lint failed"; exit 1; }

Pitfall: introducing the schema mid-flight without a migration plan leaves a graveyard of legacy keys that the linter rejects but teams are afraid to rename. Run the linter in warn-only mode for one sprint to inventory violations, then enforce on a fixed date.

Publish the pattern as a single artifact — the .flaglint.yaml above — and treat that file as the source of truth, not a prose description of it in a wiki. When the rule lives in a file the CI job reads, the rule and its enforcement can never drift apart; when it lives in a wiki page, they diverge within a quarter. The regex itself repays careful reading: ^(kill|exp|ops|[a-z][a-z0-9]*) anchors the first segment to either a reserved prefix or a lowercase-initial namespace, and the two following groups each demand a literal dot plus a kebab-safe token, so Checkout.Payments.ExpressPay, checkout..express-pay, and checkout.payments all fail loudly. Anchoring with ^ and $ is not optional — an unanchored pattern happily matches a valid key buried inside an invalid one and lets checkout.payments.express-pay-DELETEME through.

Step 2 — Enforce mandatory metadata in JSON Schema

A schema check at CI time is cheaper than a post-mortem about a flag with no owner.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "FlagMetadata",
  "type": "object",
  "required": ["owner", "type", "created", "expiry", "state", "defaultVariant"],
  "properties": {
    "owner":          { "type": "string", "minLength": 2 },
    "type":           { "enum": ["release", "experiment", "ops", "kill"] },
    "created":        { "type": "string", "format": "date" },
    "expiry":         { "type": "string", "format": "date" },
    "state":          { "enum": ["draft", "active", "deprecated", "archived"] },
    "defaultVariant": { "type": "string" },
    "ticket":         { "type": "string" }
  }
}
# Validate every flag definition file against the schema
npx ajv-cli validate -s ./flags/schema.json -d './flags/**/*.json' \
  || { echo "Flag metadata schema validation failed"; exit 1; }

Pitfall: expiry as a free-form string lets teams write "soon" or "Q3". Enforce ISO 8601 (format: "date") and add a CI step that rejects any flag whose expiry is in the past.

One caveat on format: "date": draft-07 treats format as an annotation, not an assertion, so ajv ignores it unless you initialise the validator with ajv-formats and {allErrors: true, strict: true}. A schema that looks like it validates dates but silently accepts "Q3" because the formats package was never wired in is worse than no schema, because it grants false confidence. Verify the guard actually bites by feeding it a deliberately malformed fixture in your CI test suite — a flag with "expiry": "later" that the pipeline is expected to reject — so a future dependency bump that quietly drops format validation fails a test instead of shipping. The past-expiry check is a separate assertion from the format check and belongs in its own step: JSON Schema can prove the string is a date but not that it is a future date, so the two guards are complementary, not redundant.

Step 3 — Automate expiry alerting and state transitions

Metadata only works if something acts on it. A nightly job that compares expiry dates to today and transitions flags to deprecated closes the loop without manual tracking.

#!/usr/bin/env python3
"""Nightly flag expiry checker — transitions active flags to deprecated when past expiry."""
import json, sys
from datetime import date, timedelta
from pathlib import Path

WARN_DAYS = 14   # send alert when flag is within 14 days of expiry

flags = json.loads(Path("flags/registry.json").read_text())
today = date.today()

for key, meta in flags.items():
    expiry = date.fromisoformat(meta["expiry"])
    if meta["state"] != "active":
        continue
    if expiry < today:
        # Transition to deprecated and notify
        meta["state"] = "deprecated"
        print(f"DEPRECATED: {key} (expired {expiry}). Owner: {meta['owner']}")
        # POST to flag API or write back to registry file
    elif expiry - today <= timedelta(days=WARN_DAYS):
        print(f"WARN: {key} expires in {(expiry - today).days}d. Owner: {meta['owner']}")

Path("flags/registry.json").write_text(json.dumps(flags, indent=2))

This feeds directly into the managing flag deprecation and cleanup workflow. Flags that reach deprecated state are queued for automated cleanup, which removes the flag definition and the in-code references together.

Two things make the difference between this job being useful and being ignored. First, the warning must reach the owning team’s channel, not a central firehose that everyone mutes — resolve meta["owner"] to a Slack channel or a mention group and route the message there, so the 14-day heads-up lands in front of the people who can actually extend or retire the flag. Second, the transition to deprecated must be a genuine write-back, not just a printed line; the snippet above rewrites registry.json, but in a provider-backed setup you would PATCH the flag’s metadata through the API and let the change flow through your normal review, so the state change is itself auditable. Run the job as an idempotent scheduled task — re-running it on an already-deprecated flag should be a no-op, which is why the if meta["state"] != "active": continue guard sits at the top of the loop. Without idempotency, a job that gets retried by your scheduler double-fires notifications and erodes trust in the alert the first time it cries wolf.

Step 4 — Gate namespace ownership at the CI boundary

A namespace ownership check prevents team B from accidentally creating a flag in team A’s namespace, which is the root cause of most cross-service flag conflicts.

# .github/workflows/flag-namespace.yaml
name: Flag namespace ownership check
on: [pull_request]
jobs:
  namespace-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check namespace ownership
        run: |
          for file in $(git diff --name-only HEAD~1 HEAD | grep 'flags/'); do
            # Extract namespace (first segment of each flag key in the file)
            namespaces=$(jq -r 'keys[]' "$file" | cut -d'.' -f1 | sort -u)
            for ns in $namespaces; do
              owner=$(jq -r ".\"$ns\"" .service-owners.json)
              if [ "$owner" != "$GITHUB_REPOSITORY" ]; then
                echo "ERROR: namespace '$ns' belongs to $owner"
                exit 1
              fi
            done
          done

Pitfall: shared namespaces like ops need an explicit allowlist in .service-owners.json that names multiple authorized repositories, or they will block every team from creating operational flags.

Pitfall: the git diff HEAD~1 HEAD form only inspects the last commit, which quietly misses violations introduced earlier in a multi-commit branch or squashed differently on merge. Diff against the pull request’s merge base — git diff origin/${{ github.base_ref }}...HEAD — so every flag the branch touches is checked, not just the tip commit. The check should also run on the merged result, not only the branch, because a namespace that was unowned when the branch forked may have been claimed by another team in the meantime; gating on merge-base state catches the race that gating on branch state does not.

A subtle governance decision hides in this step: whether ownership is enforced at the namespace or the service segment. Namespace-level ownership (the check above) is coarse — it stops team B writing into checkout at all — which is usually what you want, because the namespace is the domain boundary. If your domains are large enough that multiple teams legitimately share one namespace, move the check down to the service segment and map .service-owners.json on the two-segment namespace.service prefix instead. Do not try to enforce both levels at once; pick the granularity that matches how your teams are actually drawn and let the other segment be advisory.

Verification & Testing

After publishing the schema, run a full audit of existing flags to measure compliance before enforcing hard failures:

# Count flags missing mandatory metadata
jq '[.[] | select(.metadata.owner == null or .metadata.expiry == null)] | length' \
  flags/registry.json

# List flags past their expiry date (candidates for immediate deprecation)
jq --arg today "$(date +%F)" \
  '[to_entries[] | select(.value.metadata.expiry < $today and .value.metadata.state == "active") | .key]' \
  flags/registry.json

A passing baseline: zero flags with missing owner or expiry; zero active flags past their expiry date; every key matches the lint regex.

Do not stop at the point-in-time snapshot — the number that predicts whether the taxonomy is actually healthy is the trend, not the level. Track two rates over time: the median age of active flags, and the ratio of flags created to flags archived per month. A taxonomy that is working keeps median flag age roughly flat and keeps the create/archive ratio near 1; a taxonomy that is quietly failing shows median age climbing quarter over quarter even while every individual CI check stays green, because green checks prove new flags are well-formed, not that old flags are being retired. A quick way to surface the worst offenders is to sort active flags by created date ascending and look at the oldest ten — those are almost always flags that shipped, succeeded, and were never cleaned up, and they are the highest-value cleanup targets because their guarded code has been dead-obvious for the longest. Feed that list into the flag sprawl remediation workflow and work it down oldest-first.

Compliance baseline the audit must reach Three targets: zero flags missing owner or expiry, zero active flags past their expiry date, and every key matching the lint regex. missing metadata 0 owner & expiry present past-expiry active 0 all within TTL key lint 100% match the regex
Measure the baseline before enforcing hard failures: three counters that must all read zero (or 100%) before the linter flips to blocking mode.

Troubleshooting & FAQ

How do I handle flags that genuinely have no expiry?

Only ops. and kill. prefix flags are permitted to have null expiry. Set "expiry": null and document the reason in the flag’s ticket field. Everything else must have a date. Treat any release flag with null expiry as a metadata error and reject it at CI time.

Our flag keys are already inconsistent across 30 services — where do we start?

Start with the linter in warn-only mode to inventory violations without blocking anyone. Export the violation list, assign cleanup tickets to owning teams sorted by flag age, and set a 6-week enforcement deadline. For keys that cannot be renamed without a multi-repo refactor, introduce a legacy_key alias field in the metadata and handle the rename as a two-step migration: add the new key, migrate call sites, then archive the old key.

Why track lifecycle state in metadata when the SDK already has an ENABLED/DISABLED toggle?

The SDK toggle is operational — it controls evaluation today. Lifecycle state is governance — it records intent. A deprecated flag might still be ENABLED in the SDK while the code removal PR is in review. Tracking both lets you query “all flags pending code removal” without assuming DISABLED means the same thing.

Should the taxonomy live in code or in the flag vendor’s UI?

In code, as the source of truth — even when your provider offers metadata fields and a nice UI. The reason is enforcement: a JSON Schema check and a lint rule run in CI against files in a repository, where a pull request is the natural gate. A field edited through a vendor console has no pull request, no reviewer, and no diff, so a required-field rule there is a request rather than a guarantee. Use the vendor’s fields as a read replica populated from your definition files by a sync job, and treat any drift between the two as an alert. The one thing you cannot keep in files is the runtime state the provider owns — who is currently targeted, the live rollout percentage — but that is evaluation data, not taxonomy, and it belongs on the other side of the line.

How do experiment (exp.) flags differ from release flags in the taxonomy?

An exp. flag carries extra mandatory metadata that a release flag does not: an analysis_window and a stated hypothesis, enforced by the requires_fields rule in .flaglint.yaml. The lifecycle is also different in spirit — an experiment is designed to be inconclusive-tolerant and to end on a decision date, whereas a release flag is designed to reach 100% and disappear. Crucially, an experiment flag should never be repurposed as a permanent config switch once the experiment concludes; retire it and, if the winning variant needs a durable toggle, create a fresh release or ops flag. Reusing the experiment key smears two distinct intents onto one record and corrupts any later analysis that joins on it.

What TTL should we set for a flag guarding a slow, multi-quarter migration?

Keep the 90-day default and renew it deliberately rather than setting a long TTL up front. A flag with a 9-month expiry gets forgotten for eight of those months; a flag that forces its owner to re-justify its existence every quarter stays visible and honestly scoped. Each renewal is a cheap, logged decision — extend the expiry by another 90 days with a one-line reason in the ticket — and the renewal history itself becomes useful signal, because a flag on its fourth extension is a flag whose migration has stalled and deserves a hard look. Long-lived operational toggles that genuinely never expire belong under the ops. prefix with null expiry, not under a release key with a distant date.

Does the taxonomy apply to short-lived kill-switches too?

Yes, and more strictly, not less. A kill. flag skips the TTL rule — you never want a kill-switch to auto-expire out from under an incident — but it gains requirements the others lack: it must resolve over the streaming transport so a flip propagates in seconds rather than at the next poll interval, and its defaultVariant must be the un-killed state so a flag-store outage does not itself trigger the kill path. Because kill-switches are load-bearing during exactly the worst moments, hold them to the highest metadata bar: an owning team that maps to a live on-call rotation, a runbook link in the ticket field, and a periodic game-day test that actually exercises the flip. An untested kill-switch is a comforting story, not a control.