diff --git a/docs/superpowers/specs/2026-08-23-feature-flags-design.md b/docs/superpowers/specs/2026-08-23-feature-flags-design.md index cfb5325..c8acfb2 100644 --- a/docs/superpowers/specs/2026-08-23-feature-flags-design.md +++ b/docs/superpowers/specs/2026-08-23-feature-flags-design.md @@ -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