Feature Flags
Prov0.1.0Typed feature flags with per-environment kill switches, targeting rules, reusable segments, sticky percentage rollouts and a change history, evaluated in-process from a cached config.
terminalnpx ajolla add feature-flagsWhat you get
- Boolean, string, number and JSON flags with named variants and a default (off) variant
- Per-environment on/off kill switch for development, preview and production
- Ordered targeting rules on user id, email domain, org, country, versions and custom attributes
- Reusable segments, individual user overrides and sticky percentage rollouts
- Server SDK with a 30-second in-memory cache, plus <FlagsProvider> and useFlag() for React
- Admin flag editor, segment editor, "evaluate for context" tester and change history
- Works with the Vercel Flags SDK through a small adapter
Routes it adds
/admin/flags/admin/flags/[flagId]/admin/flags/evaluate/admin/flags/history/admin/flags/new/admin/flags/segments/admin/flags/segments/[segmentId]
Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.
Installs with
Kernel (added automatically).
Configuration
- FEATURE_FLAGS_ENVIRONMENT
- Which flag environment this deployment evaluates: development, preview or production. Defaults from VERCEL_ENV, else NODE_ENV.
- FEATURE_FLAGS_CACHE_TTL_MS
- How long evaluated configs are cached in memory per server instance (default 30000).
Events
Other modules (and your own code) can react to these:
feature_flags.flag_changed
Use it from any stack
Ajolla Hosted runs the same code as a multi-tenant API. Create an app in your dashboard, enable Feature Flags, and call /api/m/feature-flags/v1 with your app key.
OpenAPI document — feed it to Ajolla to generate SDKs in 7 languages and an MCP server.
AGENTS.md
Read the instructions installed with this module
markdown# Ajolla Feature Flags
Typed feature flags with a per-environment kill switch, targeting rules, reusable segments, individual overrides and sticky percentage rollouts. Flags are evaluated **in your server process** from a cached config, so a flag check costs no database round trip. "Managers" are users with the `flags.manage` permission.
## What was installed
**Admin routes** (all require `flags.manage`)
- `/admin/flags`: the flag list. Each row has a switch per environment (development, preview, production); turning a flag off is instant, turning it on in production asks first. Search, and a view of archived flags.
- `/admin/flags/new`: create a flag (key, type, variants, default/off variant, client-side).
- `/admin/flags/[flagId]`: the flag editor:
- kill switches;
- per-environment targeting: individual overrides, ordered rules (conditions + serve a variant or a rollout), the default rule with rollout sliders;
- details and variants;
- recent history;
- archive, unarchive, delete.
- `/admin/flags/segments`, `/admin/flags/segments/[segmentId]`: reusable segments, showing which flags use them.
- `/admin/flags/evaluate`: "evaluate for context". Enter a user id, email, org, country and custom attributes to see each flag's variant, value and the reason (override, rule #n, default rule, off).
- `/admin/flags/history`: every change, with who, what (summary plus before/after) and when.
**Code**
- `lib/ajolla/feature-flags/server.ts`: the server SDK: `getFlag`, `getFlagDetail`, `getAllFlags`, `currentEnvironment`, `invalidateFlagCache`. Server-only.
- `lib/ajolla/feature-flags/evaluate.ts`: the pure evaluator (overrides → rules → fallthrough, MurmurHash3 bucketing, semver).
- `lib/ajolla/feature-flags/model.ts`: types, zod schemas and limits. Safe in client code.
- `lib/ajolla/feature-flags/cache.ts`: an in-memory TTL cache that shares loads between concurrent callers and keeps serving the last good config if a reload fails.
- `lib/ajolla/feature-flags/core.ts`: database logic, framework-free. Ajolla Hosted runs the same code.
- `components/ajolla/feature-flags/flags-provider.tsx`: `<FlagsProvider>`, `useFlag`, `useFlags` (client).
- `components/ajolla/feature-flags/server-flags-provider.tsx`: `<ServerFlagsProvider>` (a Server Component that evaluates flags and feeds `<FlagsProvider>`).
**Tables** (`ajolla_feature_flags_`)
- `flags`: key (unique), description, `type` (boolean | string | number | json), `variants` jsonb, `default_variant`, `client_side`, `archived`, `version`.
- `flag_environments`: one row per flag × environment, holding `enabled` and the targeting `config` jsonb (max 256 KB).
- `segments`: key, name, `conditions` jsonb, `included` and `excluded` key arrays.
- `changes`: append-only history (`target_key`, environment, action, summary, before/after, actor).
**RLS:** only `flags.manage` holders can read or write any of these tables. Signed-in users and anonymous visitors see nothing, because targeting holds user ids and emails. History can only be inserted, as yourself. It can never be edited or deleted. The server SDK reads configs with the secret key and only ever reads.
**Events:** `feature_flags.flag_changed` `{ key, environment, action }`. `environment` is null for changes that apply to all environments. `action` is one of `created | updated | enabled | disabled | targeting_updated | archived | unarchived | deleted`. The payload never includes the config.
**Env** (both optional)
- `FEATURE_FLAGS_ENVIRONMENT`: `development`, `preview` or `production`. If unset, it's taken from `VERCEL_ENV`, then `NODE_ENV` (`production`, otherwise `development`).
- `FEATURE_FLAGS_CACHE_TTL_MS`: the cache TTL (default `30000`). Use `0` to read the database on every evaluation.
## Setup
1. Apply the migration with `supabase db push`.
2. Make sure `SUPABASE_SECRET_KEY` is set. The kernel requires it, and the SDK reads flag configs with it.
3. Create a flag at `/admin/flags/new`, read it in code, then turn it on per environment.
## Using flags
```ts
// Server Component, route handler or server action
import { getFlag, getAllFlags } from "@/lib/ajolla/feature-flags/server";
const user = await getAjollaUser();
const context = { userId: user?.id, email: user?.email, orgId, country: headers().get("x-vercel-ip-country") ?? undefined, attributes: { plan: "pro", appVersion: "2.4.1" } };
const newCheckout = await getFlag("new-checkout", context, false); // boolean
const layout = await getFlag("checkout.layout", context, { columns: 1 }); // json
const all = await getAllFlags(context); // { key: value }
```
`getFlag` returns your `defaultValue` in these cases:
- the flag doesn't exist or is archived;
- configs can't be loaded;
- the value has a different type.
Flag checks never throw.
**Context:**
- `key`: the context key used for overrides, segments and rollouts. Defaults to `userId`.
- Built-in attributes: `userId`, `email`, `orgId` and `country`. `emailDomain` is derived from `email`.
- Anything else goes in `attributes`: strings, numbers, booleans or lists.
**React (client components).** The browser never gets configs or database access. Only finished values are sent, and by default only for flags marked **client-side**.
```tsx
// app/layout.tsx (Server Component)
import { ServerFlagsProvider } from "@/components/ajolla/feature-flags/server-flags-provider";
<ServerFlagsProvider context={{ userId: user?.id, email: user?.email }}>{children}</ServerFlagsProvider>
// any client component
"use client";
import { useFlag } from "@/components/ajolla/feature-flags/flags-provider";
const showBanner = useFlag("promo-banner", false);
```
## How evaluation works
1. **Off in this environment:** the default (off) variant is served.
2. **Individual overrides:** a context key listed under a variant gets that variant.
3. **Rules, top to bottom:** the first rule whose conditions all match serves its variant or rollout.
- Operators:
- `equals`, `in` and their negations;
- `contains`, `starts_with`, `ends_with`;
- `exists`;
- number `gt`/`gte`/`lt`/`lte`;
- `semver_*` (versions like `1.4`, `v2.0.0-beta.1`);
- `in_segment`.
- Emails and domains are compared case-insensitively. Countries are upper-cased.
- Negated operators also match when the attribute is missing ("country is not one of US" matches an unknown country).
4. **Default rule:** a variant, or a percentage rollout.
**Rollouts** hash `flagKey:contextKey` with MurmurHash3 into one of 10,000 buckets (0.01% resolution).
- The same user always gets the same variant.
- Raising a percentage only adds users.
- Bucket by `orgId` to keep whole organizations together.
- Contexts without a bucketing value get the default variant.
- Setting a rollout **salt** reshuffles who lands where.
**Segments** match when:
- the key is not excluded, and
- the key is included, or every condition matches.
Segments can't nest.
**Propagation:**
- Each server instance caches one compiled config per environment for the TTL.
- The instance where you make a change drops its cache immediately. Other instances pick the change up within the TTL (30s).
- A failed reload keeps serving the last good config for up to 5 minutes.
## Vercel Flags SDK adapter
If you use the [Flags SDK](https://flags-sdk.dev) (`flags` package, not installed by this module), wrap the server SDK in an adapter:
```ts
// lib/flags.ts
import { flag, type Adapter } from "flags/next";
import { getFlag } from "@/lib/ajolla/feature-flags/server";
import { getAjollaUser } from "@/lib/ajolla/access";
import type { EvaluationContext } from "@/lib/ajolla/feature-flags/core";
function ajollaAdapter<T>(): Adapter<T, EvaluationContext> {
return {
origin: (key) => `/admin/flags?q=${encodeURIComponent(key)}`,
decide: async ({ key, entities, defaultValue }) => getFlag(key, entities ?? {}, defaultValue as T),
};
}
const identify = async (): Promise<EvaluationContext> => {
const user = await getAjollaUser();
return user ? { userId: user.id, email: user.email ?? undefined } : {};
};
export const newCheckout = flag<boolean, EvaluationContext>({
key: "new-checkout",
defaultValue: false,
identify,
adapter: ajollaAdapter<boolean>(),
});
// const enabled = await newCheckout();
```
## Limits
- 1,000 flags and 200 segments.
- Per flag: 20 variants, with JSON values up to 16 KB each.
- Per environment: 50 rules, 20 conditions per rule, 500 values per condition, and 2,000 individual overrides.
- 5,000 included and 5,000 excluded keys per segment.
These keep a config small enough to load in a few milliseconds. History rows are kept indefinitely. To prune them, delete old rows of `ajolla_feature_flags_changes` from a scheduled job (as the service role).
## Ajolla Hosted
The same flags are available over REST at `/api/m/feature-flags/v1`:
- **Secret key:**
- `POST /evaluate` evaluates every flag for a context and environment;
- CRUD for flags, targeting, segments and history.
- **Publishable key:** `POST /evaluate/client` returns only client-side flags.
Each server instance caches every app's config for 15 seconds, and responses are just `{ flags: { key: value } }`.
## Customising
- **Environments:** `ENVIRONMENTS` in `model.ts`. Also update the `check` constraints in the migration with a new migration.
- **Limits:** `LIMITS` in `model.ts`.
- **Operators:** `OPERATORS` in `model.ts` and `matchCondition` in `evaluate.ts`.
- **Production confirmation on the kill switch:** `env-toggle.tsx`.
- **Styles:** `components/ajolla/feature-flags/flags.module.css` and the kit's `--aj-*` variables.