Technical Design 0002: Declarative Feature Flag Catalog
- Issue: #11252
- RFC: RFC 0004: Add a Declarative Feature Flag Catalog
- Status: Proposed
Summary
This design implements RFC 0004’s local, bundled feature-flag catalog. It replaces the six per-app/per-build-type
FeatureFlagFactory implementations with:
- One JSON catalog (
config/featureflag/thunderbird_mobile_featureflag.catalog.json) and JSON Schema (config/featureflag/thunderbird_mobile_featureflag.schema.json), validated at build time. - A Gradle plugin (
:build-plugin:plugin’snet.thunderbird.gradle.plugin.featureflag) that validates the catalog against the schema and generates a typedFeatureFlagKeyenum from it. - A
core:featureflag(Kotlin Multiplatform) runtime that loads the bundled catalog, resolves per-app/per-build-type overrides, and layers the existing runtime debug-override mechanism on top, all behind the unchangedFeatureFlagProvider.provide(key): FeatureFlagResultfacade.
Per RFC 0004, remote catalogs, rollout targeting, and managed vendors are out of scope. This design also does not
cover RemoteCatalogFeatureFlagProvider, RemoteFeatureFlagDataSource, or TargetingKeyConfigStore — those exist in
the reference implementation as spikes for a follow-up remote-catalog phase and must not ship as part of this change.
Current State
Feature Flag catalog configuration
Feature flags are declared per app/build-type combination:
app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt
app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt
Each FeatureFlagFactory.getCatalog(): Flow<List<FeatureFlag>> hardcodes the full flag list and its enabled state for
that variant, so a single flag’s default is duplicated across up to six files.
Keys are declared in three different ways:
- A few keys live in
FeatureFlagKey.Keysincore/featureflag, explicitly marked “DO NOT ADD NEW FEATURE FLAGS HERE”. - Most keys live in per-feature
object ...FeatureFlagssingletons inside each feature’s:apimodule (e.g.MessageListFeatureFlags,MessageReaderFeatureFlags,AccountSettingsFeatureFlags,ThundermailFeatureFlags). - Some keys are still raw strings passed through
String.toFeatureFlagKey()with no compile-time registry entry at all (e.g."archive_marks_as_read").
Feature Flag value Resolution
Resolution is InMemoryFeatureFlagProvider, which combines the active FeatureFlagFactory.getCatalog() with a
FeatureFlagOverrides StateFlow and returns FeatureFlagResult.Enabled / Disabled / Unavailable. Debug
overrides come from DefaultFeatureFlagOverrides (in-memory only, wired for debug/daily); beta/release wire
NoOpFeatureFlagOverrides, which always returns no override and effectively disables the debug flag menu for those
variants. The app-facing contract is:
fun interface FeatureFlagProvider {
fun provide(key: FeatureFlagKey): FeatureFlagResult
}
sealed interface FeatureFlagResult {
data object Enabled : FeatureFlagResult
data object Disabled : FeatureFlagResult
data object Unavailable : FeatureFlagResult
}
This design keeps that contract and its ~30 call sites unchanged.
Proposed Design
Catalog file and schema
The catalog and schema live at the repository root, outside any single module, since they are consumed by both a Gradle
plugin (configuration time) and the core:featureflag runtime (packaged resource):
config/featureflag/thunderbird_mobile_featureflag.catalog.json
config/featureflag/thunderbird_mobile_featureflag.schema.json
Schema shape
Catalog shape (JSON Schema draft-07, additionalProperties: false throughout):
{
"$schema": "thunderbird_mobile_featureflag.schema.json",
"version": "2026-06-18.1",
"flags": [
{
"key": "use_compose_for_message_list_items",
"default": false,
"target_feature": ":feature:mail:message:list:api",
"description": "Renders message-list items with Compose."
}
],
"overrides": {
"thunderbird": {
"debug": {
"use_compose_for_message_list_items": true
},
"daily": {},
"beta": {},
"release": {}
},
"k9": {
"debug": {},
"release": {}
}
}
}
Fields explanation > The flags field
Required per flags[] entry:
key(pattern^[a-z0-9_]+$)default(boolean).
Optional:
descriptiontarget_feature(the Gradle module path that owns the flag, for documentation/lint purposes — no behaviour depends on it in this phase)time_to_promote(a target date for lifecycle hygiene, informational only in this phase).
Note
The
typefield is fixed to"boolean"; multivariate flags are out of scope per the RFC.
Fields explanation > The overrides field
overrides is keyed by app (thunderbird, k9) and, within each app, by build_type (debug, daily, beta,
release for Thunderbird; debug, release for K-9 Mail — matching each app’s actual build types).
Only keys that differ from flags[].default need to appear; an absent key or an absent/empty build-type block inherits
the default.
The schema enforces that overrides only references the two known apps and their known build types, and that every
override map only contains booleans (key existence against flags[] is enforced by the codegen/validation tool, not the
schema, since JSON Schema cannot express a set-membership check).
Build-time validation and code generation
net.thunderbird.gradle.plugin.featureflag.FeatureFlagPlugin is applied at the root project and exposes a
featureFlag { } extension:
// root build.gradle.kts
featureFlag {
val dir = project.layout.projectDirectory
schema.set(dir.file("config/featureflag/thunderbird_mobile_featureflag.schema.json"))
catalog.set(dir.file("config/featureflag/thunderbird_mobile_featureflag.catalog.json"))
}
On the root project, the plugin runs FeatureFlagCatalogValidator (backed by networknt/json-schema-validator) against
the catalog in afterEvaluate, and fails the build with a GradleException listing every violation if validation
fails.
On success, it registers GenerateFeatureFlagKeyEnumsTask on the owning module (:core:featureflag), which parses the
catalog and uses KotlinPoet (FeatureFlagKeyWriter) to emit one typed key enum implementing FeatureFlagKey:
// !! GENERATED FILE - DO NOT CHANGE! CHANGES ARE GOING TO BE OVERWRITTEN. !!
public enum class GeneratedFeatureFlagKey(
override val key: String,
override val description: String?,
) : FeatureFlagKey {
USE_COMPOSE_FOR_MESSAGE_LIST_ITEMS(
"use_compose_for_message_list_items",
"Renders message-list items with Compose."
),
// ...
}
Important
The intention to use an enum instead of the current approach (value class) is to enable Java code to also use the feature flag keys, without needing compatibility methods to parse a String to the value class type.
The enum is generated exactly once, into :core:featureflag’s commonMain source set (default package
net.thunderbird.core.featureflag.keys, default name GeneratedFeatureFlagKey), because every consumer already depends
on that module; generating it anywhere else risks duplicate-class failures at dex merge. This answers the RFC’s open
question on typed keys: generate, do not hand-validate the existing scattered object ...FeatureFlags declarations.
The per-feature :api module objects (MessageListFeatureFlags, etc.) and the FeatureFlagKey.Keys companion are
first marked as @Deprecated and then removed once call sites move to GeneratedFeatureFlagKey; target_feature in
the catalog is the forward-looking place to note which module “owns” a flag without needing a real per-module object.
Later, the GeneratedFeatureFlagKey can be renamed to FeatureFlagKey once the legacy is no more used in the app.
A second task, GenerateFeatureFlagRawResTask, copies the catalog JSON into a generated Android res/raw directory so
it ships as R.raw.thunderbird_mobile_featureflag_catalog on Android targets. This is wired per-module (not by the root
plugin) because the output directory is module-specific:
// core/featureflag/build.gradle.kts
val generateFeatureFlagRawRes = tasks.register<GenerateFeatureFlagRawResTask>(...) {
catalog.set(rootProject.layout.projectDirectory.file("config/featureflag/thunderbird_mobile_featureflag.catalog.json"))
resourceName.set("thunderbird_mobile_featureflag_catalog")
outputResDir.set(layout.buildDirectory.dir("generated/featureflags/res"))
}
GenerateFeatureFlagProvidersTask exists in the plugin as an empty skeleton (it only logs). It duplicates no current
behaviour and should either be implemented for a concrete need or removed; see Open Technical Questions.
Runtime catalog model
core:featureflag (commonMain) declares the deserialized catalog shape:
data class FeatureFlagCatalog<TFlagOverride : FlagRegistryOverride>(
val version: String,
val context: FeatureFlagContext,
val flags: List<FlagRegistry>,
val overrides: Map<String, TFlagOverride>,
)
data class FlagRegistry(
val key: String,
val default: Boolean,
val description: String? = null,
val targetFeature: String? = null,
val timeToPromote: String? = null,
)
interface FlagRegistryOverride {
fun forBuildType(buildType: String): FlagOverrides // FlagOverrides = Map<String, Boolean>
}
TFlagOverride is app-specific, so each app supplies its own FlagRegistryOverride implementation matching its own
build types:
// app-thunderbird
data class ThunderbirdOverrides(
val debug: FlagOverrides = emptyMap(),
val daily: FlagOverrides = emptyMap(),
val beta: FlagOverrides = emptyMap(),
val release: FlagOverrides = emptyMap(),
) : FlagRegistryOverride { /* forBuildType(...) */ }
// app-k9mail
data class K9Overrides(
val debug: FlagOverrides = emptyMap(),
val release: FlagOverrides = emptyMap(),
) : FlagRegistryOverride { /* forBuildType(...) */ }
FeatureFlagDataSource<TFlagOverride> is the loading abstraction (
suspend fun load(): FeatureFlagCatalog<TFlagOverride>), with one bundled implementation,LocalFeatureFlagDataSource (
expect/actual):
- Android
actual: readsR.raw.thunderbird_mobile_featureflag_catalogviaContext.resources.openRawResource. - JVM
actual: reads thethunderbird_mobile_featureflag.catalog.jsonclasspath resource (the same catalog file is added as ajvmMainresource incore/featureflag/build.gradle.kts), which is what makes the catalog loadable and testable from plain JVM unit tests without an Android runtime.
Resolution order
BundledCatalogFeatureFlagProvider<TFlagOverride> loads the catalog once through its FeatureFlagDataSource and
resolves, for a given app/build_type, catalog.flags.associate { it.key to it.default } merged with
overrides[app]?.forBuildType(buildType) — i.e. bundled default, overridden by the matching app/build-type entry,
exactly as RFC 0004 specifies. It also implements BundledFeatureFlagDefaults (fun defaults(): Map<String, Boolean>)
so the debug settings UI can list every flag’s variant-resolved baseline without going through the debug-override layer.
RuntimeDebugOverrideFeatureFlagProvider owns the developer-facing overrides. Unlike the current
DefaultFeatureFlagOverrides (in-memory StateFlow, lost on process death), it persists the override map through a
ConfigStore<Map<String, Boolean>> (FeatureFlagOverridesConfigStore, backed by the existing config-store/DataStore
infrastructure), exposing overrides: StateFlow<Map<String, Boolean>> plus setOverride/clearOverride/
clearAllOverrides. It is wired only where the current DefaultFeatureFlagOverrides is wired today (debug anddaily
dev-module additions); beta/release do not register it, matching today’s NoOpFeatureFlagOverridesbehaviour of
disabling the debug flag menu on those variants.
The effective per-key resolution order is:
RuntimeDebugOverrideFeatureFlagProvider— persisted local debug override, if any and if wired for this variant.BundledCatalogFeatureFlagProvider— the app/build-type override, or the flag’s bundleddefault, from the catalog.FeatureFlagResult.Unavailable— only when the key is not present in the bundled catalog at all.
This matches RFC 0004’s resolution order exactly, minus step 2 (“bundled overrides for remote catalogs”), which does not apply here since remote catalogs are out of scope.
Facade preservation and the composition mechanism
FeatureFlagProvider/FeatureFlagResult are unchanged. Instead of depending on the OpenFeature Kotlin SDK for provider
composition, a small bespoke evaluator chains an ordered list of FeatureFlagProviders and returns the first one that
has an opinion about the key, falling through to the next provider only when the current one has no value for it at all:
flowchart TD
FFP["FeatureFlag.provide(flag_key)"]
EV[Evaluator]
BFF(BundledCatalogFeatureFlagProvider)
RFF(RemoteCatalogFeatureFlagProvider)
DFF(DebugOverrideFeatureFlagProvider)
END["feature_flag[flag_key]"]
MSS["Missing flag"]
FFP -->|Get 'flag_key' value| EV
EV -->|Start evaluation| DFF
DFF --> DFF_EVAL_1{'flag_key' present in overrides}
DFF_EVAL_1 -->|Yes| END
DFF_EVAL_1 -->|No| RFF
RFF --> RFF_EVAL_1{'flag_key' present in overrides}
RFF_EVAL_1 -->|Yes| END
RFF_EVAL_1 -->|No| RFF_EVAL_2{'flag_key' present in defaults}
RFF_EVAL_2 -->|Yes| END
RFF_EVAL_2 -->|No| BFF
BFF --> BFF_EVAL_1{'flag_key' present in overrides}
BFF_EVAL_1 -->|Yes| END
BFF_EVAL_1 -->|No| BFF_EVAL_2{'flag_key' present in defaults}
BFF_EVAL_2 -->|Yes| END
BFF_EVAL_2 -->|No| MSS
The “present in overrides” / “present in defaults” branches inside each provider box are that provider’s own internal
resolution (e.g. BundledCatalogFeatureFlagProvider already merges flags[].default with the matching
overrides[app][build_type] before answering). From the evaluator’s point of view, each provider is a black box that
either has a concrete answer (Enabled/Disabled — i.e. “present”) or doesn’t (Unavailable — i.e. “not present”), so
the evaluator itself only needs to check for Unavailable:
class MultiFeatureFlagProviderEvaluator(
private val providers: List<FeatureFlagProvider>,
private val logger: Logger,
) : FeatureFlagProvider {
override fun provide(key: FeatureFlagKey): FeatureFlagResult {
for (provider in providers) {
val result = provider.provide(key)
logger.debug { "[feature-flag] '${key.key}' via $provider -> $result" }
if (result != FeatureFlagResult.Unavailable) {
return result
}
}
return FeatureFlagResult.Unavailable
}
}
-
Order is significant, so the providers are a
List, not aSet. ASetdoes not guarantee iteration order; the evaluator’s correctness depends onDebugOverrideFeatureFlagProviderbeing checked beforeRemoteCatalogFeatureFlagProvider, which in turn is checked beforeBundledCatalogFeatureFlagProvider.Additionally, as we have a small list of providers, we could inject each of them directly; however, we need to consider that this could create detekt issues later as other injections could be required later.
-
Disabledmust short-circuit exactly likeEnabled. A provider returningDisabledmeans the key is present for that provider (with valuefalse); that ends evaluation just likeEnableddoes.Only
Unavailable(“not present in this provider”) continues to the next one. TreatingDisabledthe same asUnavailablewould let a lower-priority provider silently override an explicitfalsefrom a higher-priority one.
Note
RemoteCatalogFeatureFlagProvideris shown because it is part of the general evaluation chain this evaluator is designed for, but it is out of scope for this phase (see Summary): this phase wires the evaluator with only[RuntimeDebugOverrideFeatureFlagProvider, BundledCatalogFeatureFlagProvider], in that order.The remote slot is reserved for the follow-up remote-catalog phase, which only needs to insert its provider into this same ordered list — no evaluator change required.
Wiring and debug settings UI
Each app’s Koin module provides its own FeatureFlagDataSource<TOverride> /
BundledCatalogFeatureFlagProvider<TOverride> pair and composes the provider chain, e.g. (Thunderbird):
single<FeatureFlagDataSource<ThunderbirdOverrides>> {
LocalFeatureFlagDataSource(
androidContext(),
ThunderbirdOverrides.serializer()
)
}
single {
BundledCatalogFeatureFlagProvider(
dataSource = get<FeatureFlagDataSource<ThunderbirdOverrides>>(),
logger = get()
)
}
single<BundledFeatureFlagDefaults> { get<BundledCatalogFeatureFlagProvider<ThunderbirdOverrides>>() }
DebugFeatureFlagSectionViewModel (feature:debug-settings) is updated to read the full flag registry from
GeneratedFeatureFlagKey.entries, the variant-resolved baseline from BundledFeatureFlagDefaults.defaults(), and the
active overrides from RuntimeDebugOverrideFeatureFlagProvider.overrides, applying pending changes through
setOverride/clearAllOverrides. This preserves the existing debug settings UI and its “apply and restart” flow; the
only change is where the flag list and baseline values come from.
Migration and Rollout
- Add the catalog and schema under
config/featureflag/, seeded from the union of the six existingFeatureFlagFactoryimplementations (oneflags[]entry per distinct key,defaultset to each key’s most common enabled state, per-variant deviations captured underoverrides). - Add the Gradle plugin (
FeatureFlagPlugin,SchemaValidator,GenerateFeatureFlagKeyEnumsTask,GenerateFeatureFlagRawResTask) and wire it at the root project and incore/featureflag/build.gradle.ktsVerify the generatedGeneratedFeatureFlagKeyenum contains every key from the six factories. - Add
FeatureFlagCatalog/FlagRegistry/FlagRegistryOverride/FeatureFlagDataSource/LocalFeatureFlagDataSourcetocore:featureflag, plusThunderbirdOverridesandK9Overridesin their respective apps. - Add
BundledCatalogFeatureFlagProvider,BundledFeatureFlagDefaults, andRuntimeDebugOverrideFeatureFlagProvider( withFeatureFlagOverridesConfigStore), and wire the resolution chain behind the existingFeatureFlagProviderfacade in each app’s Koin module. - Update
DebugFeatureFlagSectionViewModeland its Koin wiring to use the generated keys and the new providers. - Migrate call sites from per-feature
object ...FeatureFlagsconstants and raw string keys toGeneratedFeatureFlagKey. Call sites keep usingFeatureFlagProvider.provide(key)— only the key’s origin changes. - Delete the six
FeatureFlagFactoryimplementations,FeatureFlagFactory,FeatureFlagOverrides,DefaultFeatureFlagOverrides,NoOpFeatureFlagOverrides,InMemoryFeatureFlagProvider, and the per-featureobject ...FeatureFlagsdeclarations once every reference has moved to the catalog-backed path. - Confirm every override key in the catalog exists in
flags[]and everyoverridesentry only referencesthunderbird/k9and their real build types (enforced by the plugin; a red build blocks the migration from drifting).
Steps 1-6 can land as reviewable, additive slices while the old factories still exist and are still wired (both paths resolve the same keys to the same values, since the catalog is seeded from the factories). Step 7 is a single cleanup slice once nothing references the old types.
Testing and Verification
Schema and catalog:
- Valid catalog fixtures pass
SchemaValidator; fixtures with an unknown top-level field, a malformedkeypattern, an override for an unknown app, or an override for an unknown build type all fail. GenerateFeatureFlagKeyEnumsTask(or its underlyingFeatureFlagKeyWriter) produces one enum constant perflags[]entry, using the flag’skey(uppercased) anddescription.- A validation step (plugin-time or a dedicated test) confirms every key referenced under
overrides.*.*exists inflags[], per RFC 0004.
Runtime (core:featureflag):
LocalFeatureFlagDataSourceTest(JVM): loading the real bundled catalog resource parses the expected flag count and that boththunderbirdandk9override entries deserialize with a non-emptydebugoverride map.RuntimeDebugOverrideFeatureFlagProviderTest: overridden keys resolve to the override value; keys with no override fall through (ErrorCode.FLAG_NOT_FOUND);setOverrideis reflected in the next evaluation;clearAllOverridesremoves all overrides.FeatureFlagResultTest: the three-state facade’s helper functions (whenEnabledOrNot,onEnabled, etc.) are unaffected by the resolution-path change.- Add a
BundledCatalogFeatureFlagProvidertest (not present in the reference implementation) covering: default value when no override applies, override value when app/build-type matches, and fall-through (FLAG_NOT_FOUND) for a key absent from the catalog.
Manual verification:
- Build each app/build-type combination and confirm
GeneratedFeatureFlagKeyand the packagedR.raw.thunderbird_mobile_featureflag_catalog(Android) / classpath resource (JVM tests) resolve the same effective flags as the factory they replace, for every existing flag. - Exercise the debug settings screen on a debug build: toggle a flag, confirm it persists across an app restart (an improvement over today’s in-memory-only override), and confirm “restore defaults” reverts to the catalog-resolved baseline.
- Confirm
beta/releasebuilds do not expose the debug override menu, matching currentNoOpFeatureFlagOverridesbehaviour.
Open Technical Questions
MultiFeatureFlagProviderEvaluatoris bespoke rather than built on the OpenFeature Kotlin SDK’sFeatureProvider/MultiProvider. Confirm this holds once a follow-up RFC evaluates OpenFeature adoption — if accepted, this evaluator would need to either be replaced byMultiProvider(FirstMatchStrategy())or kept as the facade-side composition in front of an OpenFeature-backed provider.- When the remote-catalog phase lands, does
RemoteCatalogFeatureFlagProvidersimply get inserted intoMultiFeatureFlagProviderEvaluator’s provider list between the debug-override and bundled-catalog providers, or does the remote phase require its own change to the evaluator (e.g. staleness/timeout handling around the network call)? - Should catalog validation also lint
time_to_promote(e.g. warn or fail once a flag’s target date has passed) in this phase, or is that deferred until the field has a consumer? - Final home for
config/featureflag/: this design keeps it at the repository root (matching the reference implementation) since it is consumed by both a Gradle plugin and a KMP module’s packaged resources; confirm no module-local location is preferred before this ships.