docs(flags): drop the webhook — the seven-day premise was wrong

Next uses the LOWEST revalidate among a route's fetches, not the segment
value. School pages fetch school details at 300s and place pages fetch
national averages at 3600s, so the effective ISR period is five minutes
and one hour respectively — not the seven days the segment declares.

A flag flip therefore propagates on its own, well inside the monthly,
by-hand cadence these flags are for. That deletes two webhook
integrations, a revalidate route, a secret-in-query-string scheme, an
idempotency requirement, and the rule that every fetch carry a cache
tag — which was the part most likely to rot as fetches are added.

Two constraints survive: a flag must never gate content on a
force-static page, because app/admissions never revalidates; and a
route-family flag must rebuild the sitemap, deferred with the route case
since no flag in scope touches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
TudorandClaude Opus 5 committed 2026-08-23 10:40:35 +01:00
1 parent c2364bf09e
commit e2ca3d79f9
1 file changed
+39 -27
@@ -37,8 +37,9 @@ to handle them:
1. **Flag state lives outside the repository.** `main` is no longer the whole 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. 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 2. **A flag can change without a deploy**, so nothing else clears the caches
caches that a deploy would have cleared. §4 is that mechanism. 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 Also considered: Flagsmith (heavier — Django, Postgres and Redis), GrowthBook
(requires MongoDB), and Flipt v2 (the closest conceptual fit, git-native, but (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 ## 4. Propagation
School and place pages carry `revalidate = 604800`. A flag value consulted **Time-based revalidation is sufficient. There is no webhook.**
during render is baked into the cached HTML, so **polling alone changes
nothing** — the page was rendered days ago. Propagation is push, not pull.
Two webhook integrations in Unleash, both firing on feature-environment An earlier draft of this section specified two Unleash webhooks and a
enable/disable. The webhook cannot set custom headers, so each is authenticated `revalidateTag('flags')` purge, on the premise that pages cache for seven days.
by a shared secret in the query string. 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 Next uses the **lowest** `revalidate` among a route's fetches to set the
rebuilds the sitemap — a route-family flag changes which URLs exist, and the revalidation frequency of the whole route — the segment-level
sitemap is held in memory. `export const revalidate` does not override a lower value inside it. Measured
2. → `POST /api/revalidate-flags` on Next. Calls `revalidateTag('flags')`. against this codebase:
Unleash retries once and can deliver duplicate or out-of-order events, so both | Page family | Segment | Lowest fetch | Effective |
handlers are idempotent: they re-read current state rather than applying a |---|---|---|---|
delta from the payload body. | `/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 What this removes: two webhook integrations, a `/api/revalidate-flags` route, a
only the fetch of `/api/flags` itself. 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 **If instant flips are ever wanted**, the webhook is the way to add them, and it
change — is wrong in a way that fails silently. The first consumer proves it: is purely additive — nothing in this design has to change first.
`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.
Flips are rare and Next serves stale-while-revalidate, so a full purge costs a ### Two constraints this leaves behind
gradual re-render rather than a cliff. Correctness is worth more here than
precision. **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 ## 5. What "off" means, per surface