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
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.
TL;DR
maingo 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.src/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. NotNEXT_PUBLIC_*, which is frozen at build time.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
mainwithout exposing it on bluehex.au, and turn a feature back off without a revert. Pushes tomaingo 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:
practitioners.tsarray for a live Supabase read.AGENTS.mdrecords that nothing queries the database yet and "the first feature to do so is the first thing that will actually test this — a green deploy is not evidence." That cutover is on the product's only page, and being able to fall back to the static array without a revert-and-redeploy is the strongest case in the tree.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.tsalready carries the reason in a comment: Next inlinesNEXT_PUBLIC_*by matching literal text, soprocess.env[name]yieldsundefinedfor 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:
NEXT_PUBLIC_.AGENTS.mdrecords that those are baked into the artifact at build time, server bundle included, verified by grepping.next/server. ANEXT_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.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:
AGENTS.mdis 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.feature_flagstable describing flags deleted three weeks later is theconnection_checkstory 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
flagspackage) 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
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.mdsets.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.src/, covering the unset default in both directions..env.exampledocuments each flag and its default.AGENTS.mdunder Architecture: the union rule, the not-NEXT_PUBLIC_rule, the redeploy caveat, and the delete-with-the-feature invariant.docs/scope.md, which owns the cost.hitlbecause the mechanism above is a recommendation, not a decision — signing it off is the gate. Once it is signed off the implementation is mechanical.