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!
Schema:
features (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL,
description TEXT,
updated_by VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX (name, scope_key)
)
scope_key carries the scope kind inline ("user:42", "team:staff", "" for global) so the read path stays a single string lookup against a unique index.
User and team ids
UserIdField and TeamField are typed extensions stashed into the featureflag Context::extensions. Both are string-typed so torii's opaque (UUID / ULID) user ids and numeric users.id columns coexist behind the same 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.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.
