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
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