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:
- A scoped row in the
featurestable -user:42orteam:staff. - The global row in the
featurestable (scope""). - The compile-time
defaultbaked into theFeaturedeclaration.
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 Feature;
pub const NEW_CHECKOUT_FLOW: = new;
// app/src/bootstrap.rs - wire the chain once during boot.
use Duration;
use ;
pub async
// any handler - Feature::is_enabled() resolves against the per-request context.
use crateNEW_CHECKOUT_FLOW;
pub async
// flip the flag from an admin route or CLI:
use admin;
let actor_id = id; // Option<String> - None for system-initiated changes
upsert.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: =
new;
// ^ 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 is_enabled;
if is_enabled!
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:
user:{id}- when the request context carries aUserIdField.team:{name}- when the context carries aTeamField.""- the global flag.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- fromAuth::id().team- none.
Override either via the builder:
let middleware = new
.with_user_id_extractor
.with_team_from_header;
// or: .with_team_extractor(|req| your_custom_team_resolver(req))
global_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 admin;
// Create or update a global flag.
upsert.await?;
// args: name, scope_key, enabled, description, actor_id
// User-scoped override (beats the global).
upsert.await?;
// Remove a row entirely - flag falls back to compile-time default.
delete.await?;
// Read for an admin UI table.
let all_flags = list.await?;
let one_row = get.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:
Implementors react to mutations:
DatabaseEvaluator::on_flag_changedcallsself.reload()- pulls the full snapshot.CachedEvaluator::on_flag_changedcallsself.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 = new;
;
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
.await
.expect;
// 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:
- Constructs
DatabaseEvaluatoragainst the primary DB connection. - Wraps it in
CachedEvaluatorwith the requested TTL. - 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. - Builds a
CompositeFeatureSyncwith 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!
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 context;
use Arc;
let ctx = with_default;
// numeric ids still work - the framework coerces i64 → String at on_new_context time.
let ctx_numeric = with_default;
Events
Two events fire from the admin CRUD path:
Listen for them via the framework's event dispatcher to feed an audit log, Slack alert, or whatever downstream pipeline you need:
.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:
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:
async
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_enabledis the hot path. An async evaluator would force ablock_on(deadlock-prone) or push every handler to.awaiton flag reads (ergonomic disaster). The framework bridges sync ↔ async via an in-memory snapshot refreshed asynchronously byFeatureSync. -
Why a separate
FeatureSynctrait instead of extendingEvaluator? featureflag'sEvaluatoris owned by an upstream crate; we can't add methods to it.FeatureSyncis 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_flagpubonDatabaseEvaluator? Test convenience. The production write path isadmin::upsert;set_flagexists so tests can seed flags without setting up anEventFacadelistener. Both paths callfeatures::sync::notifyso the propagation contract holds either way. -
Why no
FeatureRetrievedevent? 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 -
FeatureMiddlewarebelongs afterSessionMiddleware; this chapter covers ordering and the global stack - Events - listen to
FeatureUpdated/FeatureDeletedto drive audit logs, Slack alerts, or downstream pipelines - Service Container - how the
dyn FeatureSyncbinding is resolved, and whyTestContainer::bindexists for parallel tests - Testing -
TestDatabase::fresh::<M>()andTestContainer::fakepatterns this chapter relies on - Authentication -
Auth::id()is the default user-id extractor and feedsactor_idfor 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.
