Manual contentsDigging DeeperBrowse 126 chapters
Manual 13 min read

Feature Flags

Suprnova's feature-flag system combines compile-time Feature declarations with runtime overrides persisted to a features table. A flag's value at evaluation time is determined by, in order:

  1. A scoped row in the features table - user:42 or team:staff.
  2. The global row in the features table (scope "").
  3. The compile-time default baked into the Feature declaration.

Toggles via the admin CRUD propagate to live evaluators before the mutation call returns. Kill-switch flags actually disable in real time, not "within the next TTL window."

Quick start

// app/src/features.rs - every flag your app references lives here.
use suprnova::features::Feature;

pub const NEW_CHECKOUT_FLOW: Feature<'static> = Feature::new("new-checkout-flow", false);
// app/src/bootstrap.rs - wire the chain once during boot.
use std::time::Duration;
use suprnova::features::{bootstrap_database_cached, FeatureMiddleware};

pub async fn register() {
    // ... DB::init, session, etc.

    bootstrap_database_cached(Duration::from_secs(60))
        .await
        .expect("feature flags wired");

    global_middleware!(FeatureMiddleware::new());
}
// any handler - Feature::is_enabled() resolves against the per-request context.
use crate::features::NEW_CHECKOUT_FLOW;

pub async fn index(req: Request) -> Response {
    let banner = if NEW_CHECKOUT_FLOW.is_enabled() {
        Some("Try the new checkout - faster, fewer steps.")
    } else {
        None
    };
    // ...
}
// flip the flag from an admin route or CLI:
use suprnova::features::admin;

let actor_id = Auth::id();  // Option<String> - None for system-initiated changes
admin::upsert("new-checkout-flow", "", true, None, actor_id).await?;
//                                  ^   ^                  ^
//                                  |   |                  └ audit: who toggled it
//                                  |   └ enabled
//                                  └ scope_key: "" = global, "user:42" = scoped override

The next NEW_CHECKOUT_FLOW.is_enabled() call observes true - including any cached evaluator entry, which was invalidated synchronously inside admin::upsert.

The pieces

Feature<'a>

The compile-time declaration. Carries the flag name and a default-when-absent value.

pub const KILL_SWITCH_PAYMENTS: Feature<'static> =
    Feature::new("kill-switch.payments", true);
//                                      ^ default: true (payments enabled until disabled)

Centralising every declaration in app/src/features.rs gives you:

  • a single place to grep when an operator asks "what flags exist?"
  • compile-time uniqueness for the flag name - a typo at the call site doesn't compile
  • the obvious place to put a doc comment explaining what the flag controls

Call flag.is_enabled() to read against the ambient context (set up by FeatureMiddleware) or flag.is_enabled_in(Some(&ctx)) to pass a specific Context.

The feature! and is_enabled! macros are also re-exported from suprnova::* for call sites that don't want to import the constant:

use suprnova::is_enabled;

if is_enabled!("new-checkout-flow", false) {
    // ...
}

DatabaseEvaluator

Reads the features table into an in-memory snapshot at boot and on every reload(). The hot path (is_enabled) is fully synchronous - no DB query per request, no block_on inside the evaluator.

Resolution order on lookup, most specific first:

  1. user:{id} - when the request context carries a UserIdField.
  2. team:{name} - when the context carries a TeamField.
  3. "" - the global flag.
  4. None - the row doesn't exist, the compile-time default takes over.

CachedEvaluator

Memoizes (feature, user, team) lookups behind a DashMap with a TTL you pick. The hot path stays sync; entries are dropped synchronously when admin::upsert writes a flag.

A TTL of zero degenerates to "no cache" - every call falls through to the inner evaluator. Useful for low-flag-count apps that want the propagation plumbing without the cache.

FeatureMiddleware

Opens a per-request featureflag context populated by user-defined extractors. Defaults:

  • user_id - from Auth::id().
  • team - none.

Override either via the builder:

let middleware = FeatureMiddleware::new()
    .with_user_id_extractor(|req| {
        // Custom: pull from a header instead of the session.
        req.header("X-User-Id").map(String::from)
    })
    .with_team_from_header("X-Team");
// or: .with_team_extractor(|req| your_custom_team_resolver(req))

global_middleware!(middleware);

Admin CRUD

suprnova::features::admin is the persistence layer for the features table. Use it from admin handlers, CLI tools, deployment scripts - anywhere a flag needs to flip:

use suprnova::features::admin;

// Create or update a global flag.
admin::upsert("kill-switch.payments", "", false, Some("ops-2026-05-19".into()), actor_id).await?;
// args: name, scope_key, enabled, description, actor_id

// User-scoped override (beats the global).
admin::upsert("new-checkout-flow", "user:42", true, None, actor_id).await?;

// Remove a row entirely - flag falls back to compile-time default.
admin::delete("kill-switch.payments", "", actor_id).await?;

// Read for an admin UI table.
let all_flags = admin::list().await?;
let one_row = admin::get("kill-switch.payments", "").await?;

Every mutation fires the corresponding event and calls features::sync::notify so any live evaluator bound into the App container refreshes before the call returns.

actor_id: Option<String> is the audit pointer. Pass the operator's user id (the same one your auth layer issues); leave None for system-initiated changes (CLI, deploy migration, etc.).

Flow control: flag propagation

The trait that makes "admin toggle visible immediately" work:

#[async_trait]
pub trait FeatureSync: Send + Sync + 'static {
    async fn on_flag_changed(&self, feature: &str, scope_key: &str);
}

Implementors react to mutations:

  • DatabaseEvaluator::on_flag_changed calls self.reload() - pulls the full snapshot.
  • CachedEvaluator::on_flag_changed calls self.invalidate(feature) - drops every cached entry for that name.

The canonical chain is a CompositeFeatureSync, which orders data sources before caches - caches must invalidate after the data source refreshes, or a concurrent reader can hit the empty cache, fall through to the stale data source, and repopulate the cache with the old value.

let composite = CompositeFeatureSync::new(
    vec![database.clone() as Arc<dyn FeatureSync>], // data sources first
    vec![cached.clone() as Arc<dyn FeatureSync>],   // caches second
);
App::bind::<dyn FeatureSync>(composite);

features::sync::notify(feature, scope_key) resolves Arc<dyn FeatureSync> from the container and awaits on_flag_changed. No-op when no sync is bound - the right behaviour for out-of-process admin tools that only write the DB and have no live evaluator to refresh.

Bootstrap helper

bootstrap_database_cached(ttl) wires everything in one call:

let features = bootstrap_database_cached(Duration::from_secs(60))
    .await
    .expect("feature flags wired");

// Optional: hold onto features.database to schedule periodic reloads or
// expose admin diff views. Most apps drop the handle and let
// notify-driven refresh do the work.

What it does:

  1. Constructs DatabaseEvaluator against the primary DB connection.
  2. Wraps it in CachedEvaluator with the requested TTL.
  3. Calls install_evaluator(cached) - sets the global featureflag default and flips a framework-owned "installed" tracker so the middleware doesn't log the "no evaluator" warning.
  4. Builds a CompositeFeatureSync with the right slot order and binds it into the App container.

Returns BootstrappedFeatures { database, cached } for callers that want direct handles to either layer.

If your topology isn't Cached(Database) - a Redis-backed cache, a remote sync source, a multi-tier chain - wire the chain manually using the same primitives. bootstrap_database_cached is convenience, not a contract.

Migrations

The framework owns the features table schema:

// app/src/migrations/mod.rs
vec![
    // ... your app's migrations ...
    Box::new(suprnova::features::migrations::CreateFeaturesTable),
    Box::new(suprnova::features::migrations::FeatureTimestampsToDatetime),
    Box::new(suprnova::features::migrations::FeaturesToPennantLayout),
]

The table is laravel/pennant's features, so the framework reads and writes the flags a Laravel application stored with Pennant:

features (
    id          BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name        VARCHAR(255) NOT NULL,
    scope       VARCHAR(255) NOT NULL,
    value       TEXT        NOT NULL,
    created_at  DATETIME    NULL,
    updated_at  DATETIME    NULL,
    UNIQUE INDEX (name, scope)
)

That is the MySQL shape of the table CreateFeaturesTable creates; Pennant's own migration says TIMESTAMP, and a table Pennant created is left exactly as it is. A flag's framework scope key maps to Pennant's scope:

Scope key Stored scope
"" (global) __laravel_null
user:42 App\Models\User|42, or {FEATURES_USER_SCOPE}|42
anything else, such as team:staff the key as it is

value holds JSON. The framework stores true or false, as Pennant does for an activated or deactivated flag, and reads any stored value other than false as enabled, as Pennant's active() does, so a rich value such as "blue" is enabled. Each flag's description and the actor who last changed it live in the framework's suprnova_feature_details table, because Pennant's has no column for them.

FeaturesToPennantLayout moves a features table an earlier release created (scope_key, enabled, description, updated_by) into Pennant's layout with its rows, so no flag changes its answer. FeatureTimestampsToDatetime converts the TIMESTAMP columns of such an earlier table to DATETIME on MySQL and MariaDB, keeping the stored UTC times. See Running on a Laravel Database.

User and team ids

UserIdField and TeamField are typed extensions stored in featureflag::Context::extensions. Both are string-typed so opaque framework or Magnetar user IDs and numeric app_users.id values share one evaluation shape.

Building a context manually (outside the middleware):

use featureflag::context;
use std::sync::Arc;

let ctx = featureflag::evaluator::with_default(cached.clone(), || {
    // string user ids - UUIDs, ULIDs, anything opaque.
    context! { user_id = "01HZK6V3J7Q5G4P8X9N2D1B0M3".to_string(), team = "staff".to_string() }
});

// numeric ids still work - the framework coerces i64 → String at on_new_context time.
let ctx_numeric = featureflag::evaluator::with_default(cached.clone(), || {
    context! { user_id = 42_i64 }
});

Events

Two events fire from the admin CRUD path:

pub struct FeatureUpdated {
    pub name: String,
    pub scope_key: String,
    pub enabled: bool,
    pub actor_id: Option<String>,
}

pub struct FeatureDeleted {
    pub name: String,
    pub scope_key: String,
    pub actor_id: Option<String>,
}

Listen for them via the framework's event dispatcher to feed an audit log, Slack alert, or whatever downstream pipeline you need:

EventFacade::listen::<FeatureUpdated, _>(Arc::new(FlagChangeAuditor)).await;

is_enabled does not fire a read-path event. Every request that checks a flag would multiply the event volume by the number of flags checked - fine for an audit-of-mutations story, prohibitive for read-path tracing. If your deployment needs sampled read-path audit, layer a custom evaluator that records into a bounded log channel (a Redis stream or a fanout queue, depending on scale).

Missing-evaluator detection

If FeatureMiddleware is installed but no evaluator was registered via install_evaluator / bootstrap_database_cached, every flag silently returns its compile-time default - a hard misconfiguration to catch in QA. The middleware emits exactly one tracing::warn! per process on the first request that observes this state:

WARN suprnova::features: FeatureMiddleware is in the stack but no feature-flag evaluator is installed.
     is_enabled!() calls will return compile-time defaults until features::bootstrap_database_cached(...)
     or features::install_evaluator(...) is called during app boot.

The flip uses an AtomicBool::swap so a concurrent request storm at boot serializes to a single warning emission, not one per worker.

Testing

Two patterns, depending on what you're verifying.

Unit-test a Feature in isolation

Use featureflag::evaluator::with_default to scope a stand-in evaluator inside a sync closure:

#[test]
fn flag_enabled_returns_new_path() {
    use featureflag::evaluator::with_default;
    use suprnova::features::DatabaseEvaluator;

    let flagger = Arc::new(tokio_test::block_on(async {
        let e = DatabaseEvaluator::new_in_memory().await.unwrap();
        e.set_flag("new-checkout-flow", "", true).await.unwrap();
        e
    }));

    with_default(flagger, || {
        assert!(crate::features::NEW_CHECKOUT_FLOW.is_enabled());
    });
}

DatabaseEvaluator::new_in_memory() is a test-only helper that boots its own SQLite + runs CreateFeaturesTable so the test stays hermetic. Don't use it in production paths.

Integration-test propagation end-to-end

Use TestDatabase::fresh::<TestMigrator>() for the DB and TestContainer::bind (NOT App::bind) for the FeatureSync - parallel tests on the same process would otherwise overwrite each other's binding via the global container:

#[tokio::test]
async fn admin_upsert_propagates_to_cached_chain() {
    use std::sync::Arc;
    use std::time::Duration;
    use suprnova::features::sync::FeatureSync;
    use suprnova::features::{admin, CachedEvaluator, CompositeFeatureSync, DatabaseEvaluator};
    use suprnova::features::migrations::CreateFeaturesTable;
    use suprnova::testing::{TestContainer, TestDatabase};

    struct TestMigrator;
    impl sea_orm_migration::MigratorTrait for TestMigrator {
        fn migrations() -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
            vec![Box::new(CreateFeaturesTable)]
        }
    }

    let _db = TestDatabase::fresh::<TestMigrator>().await.unwrap();

    let database = Arc::new(DatabaseEvaluator::new().await.unwrap());
    let cached = Arc::new(CachedEvaluator::new(
        database.clone() as Arc<dyn featureflag::evaluator::Evaluator + Send + Sync>,
        Duration::from_secs(60),
    ));
    let composite = Arc::new(CompositeFeatureSync::new(
        vec![database.clone() as Arc<dyn FeatureSync>],
        vec![cached.clone() as Arc<dyn FeatureSync>],
    ));
    TestContainer::bind::<dyn FeatureSync>(composite);

    let ctx = featureflag::evaluator::with_default(cached.clone(), || {
        featureflag::context! { user_id = "user-42".to_string() }
    });

    assert_eq!(cached.is_enabled("new-feature", &ctx), None);
    admin::upsert("new-feature", "", true, None, None).await.unwrap();
    assert_eq!(cached.is_enabled("new-feature", &ctx), Some(true)); // propagates instantly
}

See framework/tests/features/features.rs for the full set of composition tests.

Why Suprnova diverges

Laravel Pennant resolves every flag against the database on demand (with optional driver-level memoization per request). The PHP request-per-process model makes a per-request DB hit cheap because the connection is dedicated and dies with the request.

Suprnova's process model is the opposite - one long-running binary serving thousands of concurrent requests. A per-request DB hit on every flag check would multiply the connection pool's load by the flag-check count. The two-layer chain (DatabaseEvaluator snapshot + CachedEvaluator TTL) is the Rust-native answer: the hot path is fully synchronous against in-memory data, and the FeatureSync trait gives operator-initiated changes sub-second propagation without a polling reload. The shape is the same as Pennant - define a flag, check it in a handler, override it from an admin route. The plumbing is different because the runtime is different.

Design notes

  • Why a sync evaluator over async? featureflag's is_enabled is the hot path. An async evaluator would force a block_on (deadlock-prone) or push every handler to .await on flag reads (ergonomic disaster). The framework bridges sync ↔ async via an in-memory snapshot refreshed asynchronously by FeatureSync.

  • Why a separate FeatureSync trait instead of extending Evaluator? featureflag's Evaluator is owned by an upstream crate; we can't add methods to it. FeatureSync is a sibling trait apps implement on the same concrete types. The trait object is bound separately in the App container so a process can layer multiple evaluators while still routing notifications correctly.

  • Why is set_flag pub on DatabaseEvaluator? Test convenience. The production write path is admin::upsert; set_flag exists so tests can seed flags without setting up an EventFacade listener. Both paths call features::sync::notify so the propagation contract holds either way.

  • Why no FeatureRetrieved event? Volume. A handler checking ten flags per request fires ten events per request - for a 1k req/s service that's 36M events/hour, far above any audit pipeline's signal-to-noise ratio. Mutation-path audit (FeatureUpdated / FeatureDeleted) is what ships; read-path sampling, if needed, layers on top via a custom evaluator wrapper.

Next

  • Middleware - FeatureMiddleware belongs after SessionMiddleware; this chapter covers ordering and the global stack
  • Events - listen to FeatureUpdated / FeatureDeleted to drive audit logs, Slack alerts, or downstream pipelines
  • Service Container - how the dyn FeatureSync binding is resolved, and why TestContainer::bind exists for parallel tests
  • Testing - TestDatabase::fresh::<M>() and TestContainer::fake patterns this chapter relies on
  • Authentication - Auth::id() is the default user-id extractor and feeds actor_id for admin mutations

External: the featureflag crate docs cover the upstream Evaluator, Context, and Feature primitives. suprnova::features::admin is the full CRUD facade - cargo doc --open -p suprnova to browse.