Skip to content

0.3.0 — feature flags: two layers, and a flag is configuration not a permission

Choose a tag to compare

@cliftonc cliftonc released this 15 Sep 04:27
· 281 commits to main since this release
0.3.0
cffb14a

Not breaking; one migration. Read the porting note before porting any of it — pnpm kit:upgrade follows that note, and it is what tells your copy which parts of this release still apply to it.

This closes the gap docs/CONCEPTS.md §1 has recorded since 0.1.0: "feature-flag source for access is undecided (inject features, keep it open)." The path already existed end to end and carried an empty array. Now it has a source — and a settled answer to what a flag actually is.

Two layers, because there are two questions

  • Environment — FEATURES_ENABLED in [vars]: does this surface exist in this deployment at all? Fail-closed (blank means none), consulted only for a flag marked environmentGated, so an ordinary rollout flag needs no toml edit. This is what lets half-built code be released to production dark.
  • Rollout — feature_flags + tenant_feature_overrides: among deployments where it exists, which organisations have it yet? off | on | rollout, a percentage and the unit it counts, plus per-organisation overrides — driven from /admin/feature-flags with no redeploy.

An override beats the rollout in both directions, because that is what it is for: the design-partner allow-list and the "never for this customer" block-list. So the UI offers On / Off / Default, not a checkbox.

Flag keys are code (FEATURES + FEATURE_FLAGS in packages/shared), following the PROMPT_REGISTRY and AGENT_KEYS pattern: adding one needs no migration, and requireFeature('new-reprots') is a type error rather than a route that 404s for ever.

A flag is configuration, not a permission

Take this rule even if you skip everything else. Every gate reads auth.features / session.features — never the CASL ability. globalAdmin is manage all and support is granted access all, and in CASL those wildcards cover access on every Feature: subject, so an ability check hands platform staff a surface the deployment does not ship. An app built on this kit shipped exactly that and had five routes open in production to staff, while its cube and dashboard gates — which read the array — stayed dark. Two sources of truth disagreeing.

applyFeatureFlags still populates Feature:<name> for an app that genuinely wants permission-style entitlements. Nothing that hides a dark surface may use it.

A feature ships dark on every door

Three of them have no nav entry, and one of those creates data:

  • API mounts — an optional third element in the mount table → 404 feature_disabled, not 403, which would confirm the feature exists.
  • The cube registry — cubesFor(features), filtered per request so allCubes stays whole and the isolation test still proves every cube's tenant scoping.
  • Dashboard templates — ensureDefaultDashboards runs lazily on every GET /pages, so an ungated template seeds itself into every organisation on the first load after a deploy.
  • Nav, routes and settings tabs — NavGuard gains a { feature } form and a list meaning AND, so the flag and the permission stay two readable facts.

Also in this release

  • featureBucket is a wire format: FNV-1a over "<key>:<unitId>", mod 100. The percentage is never hashed in, which is what makes a rollout monotonic — raising it only ever adds, and nobody is dropped from one that grows. Golden vectors pin it.
  • Resolution costs no extra round trip on the cookie path (a third LEFT JOIN LATERAL in resolveSession); the Bearer path reads its rows alongside groups under one Promise.all, with no cache to go stale.
  • Single-tenant mode works unchanged — it always has a global admin by construction. The override routes 404 there (with one organisation the platform state already is its answer), and a rollout counted in organisations is refused, because over one organisation a percentage is all-or-nothing decided by an opaque hash.
  • GET /api/features and rocketflare features list for the effective list; a features.changed realtime nudge; a FeatureFlag platform CASL subject.
  • Cloudflare Flagship was considered and rejected — its tenancy model is your Cloudflare account, not your customers; a toggle is a full-object PUT with no ETag, so two admin clicks are a lost update; and there is no local flag store, which would break clone-and-run. docs/CONCEPTS.md §15 records the reasons, and that it remains the right tool for rolling out the kit's own code.

Known limitation, stated rather than hidden: the gated code still ships in the browser bundle. Client-side hiding is cosmetic; the server is the protection.

Upgrading a copy

pnpm kit:upgrade --to 0.3.0        # plan
pnpm kit:upgrade --to 0.3.0 --apply

It skips anything belonging to a surface you deleted, and never applies a kit migration — port the schema and run your own pnpm db:generate. If you already had a [vars]-only feature flag, union it into resolveFeatures rather than replacing it: that function is the single seam, and every consumer downstream reads the array it returns.

What's Changed

  • Feature flags (D30), and release 0.3.0 by @cliftonc in #18

Full Changelog: 0.2.0...0.3.0