Skip to content

Feature flags: one typed module over environment variables, not a table #93

Description

@davidtaing

TL;DR

  • The problem — pushes to main go straight to production, so the only way to hold a half-built surface back is to hold the branch back. That makes the merge the risky act rather than the deploy.
  • The fixsrc/lib/flags.ts: a closed union of flag names over server-only environment variables, each with its own default stated at the declaration, each naming the issue that deletes it. Not a table — the flag that turns off a broken database read would itself be a database read. Not NEXT_PUBLIC_*, which is frozen at build time.
  • To decide — whether to build it at all yet. Nothing in the tree needs a flag today, and this should not land before its first consumer; The directory reads from the database #53 is the strongest candidate. If The directory reads from the database #53 lands cleanly without wanting one, close this.
Full context — the reasoning, the constraints and what has already been ruled out. This is the part a coding agent should read.

A flag here is a kill switch and a launch switch: ship a half-built surface to main without exposing it on bluehex.au, and turn a feature back off without a revert. Pushes to main go straight to production, so today the only way to hold something back is to hold the branch back — which is what makes the merge the risky act rather than the deploy.

Nothing in the tree needs a flag today, and this should not land before its first consumer. That is deliberate rather than a hedge: a flags module built ahead of a caller is machinery defending a decision nobody has made yet, and this repository has the #32 story about exactly that. The first real consumer is one of:

Read those as the trigger. If #53 lands cleanly without wanting one, close this.

What is being proposed: one typed module over environment variables

src/lib/flags.ts, server-only variables, flags named as a closed union so a typo is a type error rather than a silently-false flag.

The union is the part that matters. src/lib/supabase.ts already carries the reason in a comment: Next inlines NEXT_PUBLIC_* by matching literal text, so process.env[name] yields undefined for a name assembled at runtime. Any dynamic lookup means a misspelled flag reads as off — which fails open for a kill switch and closed for a launch switch. Both are wrong and neither raises anything.

Two further rules that fall out of it:

  • Not NEXT_PUBLIC_. AGENTS.md records that those are baked into the artifact at build time, server bundle included, verified by grepping .next/server. A NEXT_PUBLIC_ flag is frozen at build and cannot be flipped without a rebuild, which is the opposite of what a flag is for. Server-only names are read at request time.
  • The default when unset is per flag, stated at the declaration, never a global default. A launch switch defaults off; a kill switch defaults on. Getting this backwards is the whole failure mode.

Stated plainly because it is the real limitation: on Vercel an environment variable change takes effect on the next deployment, not on the running one. So this buys "flip without a code change, a review or a rebuild of the branch", not "flip in five seconds". If the requirement is genuinely seconds, Vercel's instant rollback already covers the kill-switch case and this issue is the wrong tool.

Why not a table in Supabase

It was the first suggestion and it is the wrong shape here, for one decisive reason and three supporting ones.

The decisive one: it inverts the dependency. The flag that would let you turn off a broken database read is itself a database read. When Supabase is the thing that is wrong — which is the case the kill switch exists for — the switch is down too, and the fallback default becomes load-bearing at exactly the moment nobody can change it.

The rest:

  • Every flag read is a request-time round trip on pages that are otherwise static. One flag read on the home page makes the home page dynamic.
  • It costs a table with its own grants, RLS and an admin-only write policy. AGENTS.md is explicit that this is the privilege layer that fails silently and that a missing grant looks like a broken policy — real, load-bearing work, spent on something that changes a handful of times a year.
  • Migration history is permanent. A feature_flags table describing flags deleted three weeks later is the connection_check story again.

Where a table would genuinely win, stated fairly rather than dismissed: per-user targeting, percentage rollout, and flipping from an admin surface without a deploy. None of those is in milestone 1 — there are no accounts until #83, and the directory launches at three profiles, so there is nobody to roll a percentage out to. If per-user targeting arrives later, revisit this rather than bending the env-var module towards it.

A hosted flag service (LaunchDarkly, Vercel's flags package) is also out: a vendor and an account for a one-pager, and in the Vercel case a build-time dependency landing under the 7-day release floor.

Invariants

  • A flag is deleted with the feature it gated. A flag that outlives its rollout is the actual failure mode: dead branches accumulate and nobody can say which state production is in. Every flag added names the issue that removes it, in the declaration.
  • No flag gates verified, or anything on the path to it. The badge is the product. A switch that can turn credential checking on or off is a way to break the one invariant the schema exists to protect, and it would not look like one in a diff.

Both are violable and both are visible in a diff, which is the bar AGENTS.md sets.

Acceptance

  • src/lib/flags.ts — a closed union of flag names, each with an explicit default and a comment naming the issue that deletes it. Variables written out in full, never indexed dynamically.
  • At least one real caller. No module lands with zero consumers.
  • Unit tests under src/, covering the unset default in both directions.
  • .env.example documents each flag and its default.
  • A short section in AGENTS.md under Architecture: the union rule, the not-NEXT_PUBLIC_ rule, the redeploy caveat, and the delete-with-the-feature invariant.
  • A row in docs/scope.md, which owns the cost.

hitl because the mechanism above is a recommendation, not a decision — signing it off is the gate. Once it is signed off the implementation is mechanical.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: appApplication code outside UI and schemaenhancementNew feature or requesthitlNeeds a human (decision/design/review)size: SAn evening for someone new to this codebase

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions