feat(flags): ship-dark feature flags, with last-distance-offered behind the first one #125

Merged
tudor merged 10 commits from feat/feature-flags into main 2026-08-23 11:34:56 +00:00
Showing only changes of commit e2ca3d79f9 - Show all commits
@@ -37,8 +37,9 @@ to handle them:
1. **Flag state lives outside the repository.** `main` is no longer the whole
truth about what is switched on. The registry in §2 exists to bound that.
2. **A flag can change without a deploy**, so nothing else invalidates the
caches that a deploy would have cleared. §4 is that mechanism.
2. **A flag can change without a deploy**, so nothing else clears the caches
that a deploy would have cleared. §4 establishes how long a flip takes to
become visible, and why that is short enough to need no extra mechanism.
Also considered: Flagsmith (heavier — Django, Postgres and Redis), GrowthBook
(requires MongoDB), and Flipt v2 (the closest conceptual fit, git-native, but
@@ -140,38 +141,49 @@ named constant with a comment saying what belongs on it.
## 4. Propagation
School and place pages carry `revalidate = 604800`. A flag value consulted
during render is baked into the cached HTML, so **polling alone changes
nothing** — the page was rendered days ago. Propagation is push, not pull.
**Time-based revalidation is sufficient. There is no webhook.**
Two webhook integrations in Unleash, both firing on feature-environment
enable/disable. The webhook cannot set custom headers, so each is authenticated
by a shared secret in the query string.
An earlier draft of this section specified two Unleash webhooks and a
`revalidateTag('flags')` purge, on the premise that pages cache for seven days.
That premise was wrong, and checking it removed the most complex part of the
design.
1. → `POST /api/admin/flags-changed` on FastAPI. Refreshes the SDK cache, and
rebuilds the sitemap — a route-family flag changes which URLs exist, and the
sitemap is held in memory.
2. → `POST /api/revalidate-flags` on Next. Calls `revalidateTag('flags')`.
Next uses the **lowest** `revalidate` among a route's fetches to set the
revalidation frequency of the whole route — the segment-level
`export const revalidate` does not override a lower value inside it. Measured
against this codebase:
Unleash retries once and can deliver duplicate or out-of-order events, so both
handlers are idempotent: they re-read current state rather than applying a
delta from the payload body.
| Page family | Segment | Lowest fetch | Effective |
|---|---|---|---|
| `/school/[slug]` | 604800 | `fetchSchoolDetails` at 300 | **5 minutes** |
| `/schools/*` | 604800 | `fetchNationalAverages` at 3600 | **1 hour** |
### Cache tagging: coarse, deliberately
The Unleash SDK polls every 15 seconds, so a flip reaches school pages within
about five minutes and place pages within the hour, unaided. Flags flip
monthly, by hand, deliberately. That is fast enough.
**Every server-side fetch in `nextjs-app/lib/` carries the `flags` tag**, not
only the fetch of `/api/flags` itself.
What this removes: two webhook integrations, a `/api/revalidate-flags` route, a
shared-secret-in-a-query-string scheme, an idempotency requirement against
duplicate and out-of-order delivery, and a rule that every fetch in
`nextjs-app/lib/` carry a cache tag. None of it has to be built, maintained, or
kept correct as new fetches are added.
The tempting rule — tag only those fetches whose response shape a flag can
change — is wrong in a way that fails silently. The first consumer proves it:
`admission_distance` changes the response of `/api/schools/{urn}`, not of
`/api/flags`, so a narrowly-tagged purge would leave ~25,000 school pages
serving the pre-flip render for up to seven days. The failure is invisible
locally and visible to Google.
**If instant flips are ever wanted**, the webhook is the way to add them, and it
is purely additive — nothing in this design has to change first.
Flips are rare and Next serves stale-while-revalidate, so a full purge costs a
gradual re-render rather than a cliff. Correctness is worth more here than
precision.
### Two constraints this leaves behind
**Never flag content on a `force-static` page.** `app/admissions/page.tsx`
declares `export const dynamic = 'force-static'`, so it is baked at build time
and never revalidates. A flag gating anything on such a page would not take
effect until the next deploy, silently. If a flag ever needs to reach one, that
page must first move to ISR.
**A route-family flag still needs the sitemap rebuilt.** The sitemap is held in
memory and rebuilt only at startup or via `POST /api/admin/regenerate-sitemap`.
No flag in scope touches the sitemap (§6), so this is deferred with the route
case rather than solved now — but a route flag must not ship without it, or the
sitemap will advertise URLs that `notFound()`.
## 5. What "off" means, per surface