CMS → Next: live content revalidation
When an editor saves a translation in Filament (blei-cms), the change propagates to every Next pod within seconds, across all 26 sites, without a rebuild. This is the full plan — architecture, contract, every work unit, the decisions behind them, and what's still open.
1Goal
Editor saves in Filament → the change is live on all Next pods within seconds, across all 26 sites, with no downtime and the CMS database hit only a small bounded number of times per save.
| Today | After this work |
|---|---|
Next renders from a bundled cms.json baked in at build time. A content change needs a redeploy. | Next fetches per-domain from CMS live, with tag-based invalidation over a shared Redis cache. A save propagates lazily (stale-while-revalidate) in seconds. |
2Architecture
The spine is Kairo's original sequence: back-end pushes a "you're stale" signal → front-end flips a flag in shared Redis → lazy regeneration on the next request. The webhook carries no content — Next pulls fresh data itself, lazily, so no user ever waits behind a rebuild.
End-to-end flow
GET /api/content/{id}) and writes the fresh result to shared Dragonfly — so every other pod reads it without touching CMS again.System topology
Every Next pod is identical and serves all 26 domains (resolved per-request from the host header — D30). They share one Dragonfly, so a tag invalidation written by any pod is seen by all, and a single regen populates the cache for the whole fleet. That shared cache is what makes the design work; it isn't future-proofing.
Two Redis instances (D20)
- FE Redis — Dragonfly, in-kube. Shared across all Next pods via
@fortedigital/nextjs-cache-handler(master-replica, node-redis). Holds rendered pages, the data cache, and tag-staleness state. CMS never touches it. - CMS Redis — vanilla, VM-local. On the CMS VM, localhost-bound, provisioned via the C5 Ansible MR (no manual
apt installon prod — house policy). Backs both the Laravel content cache (cms.content.{id}.v{gen}) and the Horizon queue, on separate DB indexes. Protects the DB from concurrent regens.
Cloudflare is deliberately not in front (D21) — Fortedigital + Dragonfly is the L1 cache, and Cloudflare wouldn't resolve the concurrent-regen race the way shared Redis does.
Request & cache resolution
3The contract
The cross-team boundary — the one thing that must not drift silently. Two bearer-auth endpoints. domainId is the underscored canonical form on the wire (D38) so CMS tags and Next page tags always match. The webhook is idempotent, at-least-once, order-insensitive.
# CMS → Next : "you're stale" — invalidation only, no payload POST {NEXT_REVALIDATE_URL}/api/revalidate Authorization: Bearer ${CMS_REVALIDATE_SECRET} { "domainId": "balticlei_ee" } // or null = global-default save → 200 { revalidated, tag, now } → 400 bad body · 401 bad bearer · 403 domainId not in allowlist · 500 unexpected # Next → CMS : pull fresh content for ONE domain (lazy, on regen) GET {CMS_API_BASE_URL}/api/content/{domainId} Authorization: Bearer ${CMS_CONTENT_TOKEN} → { sites: { "balticlei_ee": { block_groups, blog_posts } } } # Tags & keys Next page cache tags : ['cms', 'cms:'+domainId] CMS BE cache key : cms.content.{domainId}.v{gen} // version-keyed (D39) global-default save : domainId = null → tag 'cms' → forget all 26
4Data model & the global default
CMS content is group → block → translations[lang], enumerated from config('blocks.groups') (56 translation models). For every block, ensureDefaultRows() seeds a default row with domain = null per language; each site inherits that default until it sets its own override. So a NULL-row save legitimately invalidates every domain (D8/D27/D37).
notFound(), not a global tag.5Workstreams
Each task cites the decisions behind it (full rationale in §9). Three streams, built in slices (§6).
Ablei-cms — Laravel 12 + Filament → main
| # | Task | Refs |
|---|---|---|
| A1 | Wire Redis + Laravel Horizon queue. Redis (localhost) backs both cache and queue — provisioned via the C5 Ansible MR, not a manual apt install (house policy: no apt on prod). composer require laravel/horizon (existing phpredis client — no predis). QUEUE_CONNECTION=redis, CACHE_STORE=redis using discrete REDIS_DB/REDIS_CACHE_DB (not REDIS_URL — D40). DB indexes are already separated in config/database.php. Deploy restart is already handled by the house deployer — just set('horizon_enabled', true) in deploy.php (no manual hook). viewHorizon gate. Mirror the house Horizon exemplar spain-re-scraper (Reigo added it recently). Redis install + durability (noeviction+AOF, D40) + systemd ship via C5. | D20·D35·D40 |
| A2 | Create a bearer middleware (hash_equals vs CMS_CONTENT_TOKEN) on both content routes — they have no auth today, so this is net-new, not "mirror the bulk endpoint". Read gen once (observer owns the bump, D39), then Cache::lock(…)->block() + Cache::remember('cms.content.{id}.v{gen}', …) — version-keyed; remember alone doesn't single-flight. {id} underscored (D38). | D20·D38·D39 |
| A3 | Add GET /api/content/{domain} route. ContentController::show() calls new ContentService::getContentForDomain() (extract inner loop of getAllContent). Pass the path param through the shared domainKey() helper — a defensive no-op (the DB already stores underscored, D38). Same bearer as bulk. Reproduces the blocks.php duplicate-key shape (D18) — out of scope here. | D6·D38 |
| A4 | RevalidationDispatcher service. ->dispatchFor($model) resolves domain from $model->block?->domain or $model->domain, converts to underscored (D38), then dispatches RevalidateFrontendJob. | D8·D10·D38 |
| A5 | ContentRevalidationObserver on every Block*Translation + BlogPost, updated + afterCommit. Guard a null block row (loadMissing('block')) so it can't coerce to null → forget all 26. No-op via getDirty ∩ getFillable, excluding updated_at/image churn. Per domain, in order (D28): Cache::increment('cms.gen.{id}') → forget → dispatch; stagger the NULL-row loop so 26 cold rebuilds don't fire at once (D39). Adds attach/detach to DomainLanguageRepository. | D10·D17·D27·D28·D38·D39 |
| A6 | RevalidateFrontendJob: ShouldQueue, afterCommit, tries=5, exponential backoff, 10s timeout. POSTs to config('services.next.revalidate_url') with bearer. Context-array logging; Sentry on hard failure (ISR backstop catches up). | D13·D28 |
| A7 | Feature tests (Bus::fake() + Http::fake()): save dispatches; bootstrap rows don't (ensureDefaultRows no-fire); NULL-domain save forgets all keys + dispatches domainId:null; happy-path 200. Job::dispatch() per AGENTS.md. | D10·D17 |
| A8 | php artisan cms:revalidate {domain?} — ops escape hatch (global or per-domain). | — |
| A9 | REVALIDATION_ENABLED env flag — observer no-ops when false (kill switch / deploy ordering). | D12 |
Bblei-next — Next 16 App Router → develop
| # | Task | Refs |
|---|---|---|
| B1 | Bump Next 15.5.9 → 16.x. Add @fortedigital/nextjs-cache-handler@^3.2 + redis (node-redis, NOT ioredis — handler-enforced). Stay off cacheComponents/'use cache' (D3/D36). Node ≥ 22 already met; pin engines.node. Flush Dragonfly once on the major bump. Land the bare 15→16 bump as its own step before B2/B3 (async params already done on develop; real risk is the cacheHandler/Turbopack interaction). | D3 |
| B2 | cache-handler.mjs at repo root. Composite LRU + Redis; global singleton via createClient (master-replica, not createCluster — D32). Reads REDIS_URL, CACHE_HANDLER_PREFIX=nextcache. Log Redis errors to Sentry. | D2·D29·D32 |
| B3 | next.config.ts: cacheHandler: require.resolve('./cache-handler.mjs'), cacheMaxMemorySize: 0. Verify Turbopack compat (turbopack.root is unusual — smoke test). | D2 |
| B4 | Edit existing instrumentation.ts (already has Sentry register): add registerInitialCache({ setOnlyIfNotExists: true }) inside register() so rolling deploy doesn't clobber warm cache. | D2 |
| B5 | Rename getDomainTranslation.ts → getCMSData.ts — file/import-path rename only, mechanical (TS-checked). Exported fn already named getCMSData. (The fetch-granularity signature change is B6, not this — that one is not mechanical.) | D26 |
| B6 | Rewrite getCMSData(domainId) as live per-domain fetch wrapped in unstable_cache(…, {tags:['cms',\`cms:${id}\`], revalidate:600}). Keep unstable_cache, NOT 'use cache' (D36). Return the same envelope { sites: { [id]: … } } so all 43 cmsData.sites[id] callers compile unchanged (D26). id underscored (D38). Fetch ${CMS_API_BASE_URL}/api/content/${id} + bearer. Scope dev validateCmsData to the one domain (it loops all 26 → would throw on a single-domain payload). Cutover fallback: fall back to bundled cms.json when the fetch is unavailable (D41). Live fetch lives inside getCMSData (MOCK_CMS_DATA is only a comment, no real seam). | D6·D11·D19·D38·D41 |
| B7 | Add getAllCMSData() → { sites:{…all…} } (loops DOMAINS_CONFIG). Only confirmed caller: scripts/warmup.ts (legal_disclosure has no generateStaticParams — see B14). | D6 |
| B8 | app/api/revalidate/route.ts. POST-only, constant-time bearer-verify (timingSafeEqual), parse {domainId} (underscored). null → revalidateTag('cms','max'); else allowlist-check via the house isDomainId() helper (checks the .domainId value set, NOT the host-derived config keys — D38) → revalidateTag('cms:'+id,'max'). A mismatch is a 403, not a silent miss. | D4·D5·D8·D25·D38 |
| B9 | Vitest for /api/revalidate: 401 bad bearer; 400 missing body; 403 unknown domain; null → global tag; happy path; idempotent. Add app/** to vitest.config.ts include (currently src/** only) or colocate, else route tests don't run. | — |
| B10 | Data guards on all three legal pages (D24, review-corrected). No dynamicParams flip needed. Add notFound() for missing CMS data on legal_disclosure (today only guards a missing slug → 500 on a missing block) and on privacy_policy + terms_of_service (no guards at all). | D24 |
| B11 | Env wiring: CMS_API_BASE_URL, CMS_CONTENT_TOKEN, CMS_REVALIDATE_SECRET, REDIS_URL, CACHE_HANDLER_PREFIX (keep CMS_BASE_URL for image proxy). Helm: add two externalSecrets entries (cms/redis → lei-front-next/*-{env}) AND append both to deployment.envFrom: secretRef — externalSecrets only creates the secrets; envFrom injects them. | D13·D14 |
| B12 | Remove bundled cms.json runtime path (keep file as test fixture). | — |
| B13 | Bump replicaCount after Slice 2 verifies shared cache (value per Erki). | — |
| B14 | CMS dependency is at runtime warmup, not CI build (corrected D31): generateStaticParams returns [] and warmup.ts hits a running server. Real need: the pod has CMS_API_BASE_URL + CMS_CONTENT_TOKEN (covered by B11). Only add to CI if a future task fetches CMS in generateStaticParams. | D31 |
CInfra / shared — Erki
| # | Task | Refs |
|---|---|---|
| C1 | Provision FE Dragonfly (master-replica) in-kube (K8s reference repo: baltic-lei/infrastructure/kube-lab) + Vault entries for the new secrets. | D2·D13·D14 |
| C2 | Observability: Sentry both sides (already installed in blei-next) + dashboards (queue throughput, retries, revalidate latency, cache hit/miss, 4xx/5xx, Redis-down) — plus Horizon queue-depth / oldest-pending-job age, Redis & Dragonfly used_memory/evicted_keys (D40), and failed_jobs rate (evicted jobs never reach the Sentry failure path). | D29·D40 |
| C3 | Publish the multi-pod verification runbook (§7). | — |
| C4 | Cloudflare integration — deferred, out of scope. | D21 |
| C5 | Ansible MR to baltic-lei/infrastructure/ansible (Steven authors, Erki reviews): Horizon systemd unit + install & configure Redis on the CMS VM (this MR is where the Redis install lives — no manual apt on prod). Must include Restart=always + a liveness check (systemd won't catch a hung Horizon), a memory bound (child processes leak), and Redis noeviction + AOF (D40 — else queued jobs are silently evicted/lost). Prereq for A6 jobs to run. | D35·D40 |
6Delivery slices & milestones
Each slice ships independently; pause for human review at every boundary.
Two local Next pods (force ≥2 replicas — staging is 1 today, so the race isn't live until then) + shared Redis + Fortedigital. Tagged fetch → invalidate → hit both pods with N concurrent requests. Measure cross-pod staleness-visibility latency (the real open question) and DB rebuilds — for a single-domain and a NULL-row save. Criterion: DB rebuilds ≤ 1 per domain (bounded by the CMS-side lock + version key), not "≤2 HTTP hits". 🔴 Hard gate (NEW): Next 16 needs refreshTags() for cross-instance invalidation, which Fortedigital 3.2 doesn't implement — so "all pods see stale" rests on its legacy redis-strings tag-map. Two-pod cross-invalidation must PASS as an explicit gate (pod A invalidates → pod B serves fresh), not a smoke test. Bake-off (D42): run the harness against both Fortedigital and nextjs-turbo-redis-cache (challenger — <100ms Pub/Sub); pick the winner on cross-pod latency. Architecture doesn't change either way (D16/D30).
B1B2B3B4A1B5
✓ Verifiable: two Next instances locally share rendered pages via Redis.
A2A3B6B7B11B12A4A5A6A7A9B8B9A8
✓ Verifiable end-to-end: edit in Filament → fresh content on Next within seconds.
B10B13C2C3
✓ Guards, replica bump, dashboards, runbook published.
Execution milestones & parallel lanes
The slices above are the what. For execution, work is organized into milestones (each = one mergeable, review-gated MR) holding slices (one cohesive test-then-build loop each; a slice never spans two MRs). Tasks build ping-pong TDD (failing test → pass → commit). Sessions persist across milestones — the review gate is also the natural compaction checkpoint.
| MR | Lane | Slices (loops inside) | Dep |
|---|---|---|---|
| M0 | scratch | Cache spike + Forte-vs-turbo bake-off → decision (no MR) | GATE |
| M1 | next | Next foundations: bump (B1) · handler+config (B2,B3) · instrumentation+rename (B4,B5) | after M0 |
| M2 | cms | CMS content API: Redis/Horizon+DomainKey (A1) · endpoint (A3) · bearer+version-cache (A2) | ‖ M1 |
| M3a | cms | CMS dispatch: job (A6) · dispatcher+kill-switch (A4,A9) | after M2 · ‖ M4a |
| M3b | cms | CMS trigger: observer+repo (A5) · e2e tests+command (A7,A8) | after M3a |
| M4a | next | Next live fetch: getCMSData (B6) · getAllCMSData+migration (B7) | after M1 · ‖ M3a |
| M4b | next | Next push+wiring: /api/revalidate+tests (B8,B9) · env/helm+drop cms.json (B11,B12) | after M4a |
| M5 | both | Hardening: legal guards (B10) · replicas+dashboards+runbook (B13,C2,C3) | after Sync B |
| Infra | Erki | C1 Dragonfly+Vault · C5 Horizon ansible | C5 before M3a deploy |
Parallel lanes: cms-lane (M2→M3a→M3b) ‖ next-lane (M1→M4a→M4b) — serial within a lane, parallel across (~2× throughput). 3 sync points (not MRs): SYNC 0 = Slice-0 gate (handler + cross-pod proof) · SYNC A = pin the cross-repo contract before Slice-2 build · SYNC B = Slice-2 e2e (CMS deploys first, D41).
(1) every unit has good tests per the *-testing-reference skills · (2) holistic review of the whole diff (architecture fit, helper reuse, FSD / Service-Repo boundaries) · (3) pattern + codestyle vs the *-standards-reference skills + AGENTS.md · (4) run critique-my-branch, fix findings · (5) human sign-off → compact → next milestone.
7Verification runbook
Manual multi-pod check for Slice 2 sign-off (Steven runs this personally before prod).
# 1. Local Redis docker run -p 6379:6379 redis:7 redis-server --notify-keyspace-events Egx # 2. CMS locally with: REDIS_CLIENT=phpredis, CACHE_DRIVER=redis, REVALIDATION_ENABLED=true, # NEXT_REVALIDATE_URL=http://host.docker.internal:3000/api/revalidate, CMS_REVALIDATE_SECRET, CMS_CONTENT_TOKEN # 3. Two Next pods sharing one Redis PORT=3000 REDIS_URL=redis://localhost:6379 CMS_API_BASE_URL=http://localhost:8090 … npm run build && npm run start & PORT=3001 REDIS_URL=redis://localhost:6379 … npm run start & # 4. Warm both pods 5. Trigger: docker exec blei-cms-app php artisan cms:revalidate balticlei_ee curl :3000/balticlei_ee/et # serves stale, triggers regen sleep 5 ; curl :3001/balticlei_ee/et # fresh, from shared cache
- Both pods serve fresh content within ~30s; SWR observed (no user waits behind regen).
- Single-domain save: CMS DB hit a small bounded number of times (≤3 fine).
- NULL-row (global) save: bounded by ≤26 × single-flight (one rebuild per domain), not 26 × pods — verify
Cache::lock()->block()serialises and the version key (D39) prevents stale writes. - Edge cases: bearer mismatch → 401;
REVALIDATION_ENABLED=false→ no webhook; Dragonfly down → LRU fallback + Sentry alert.
8Reference tables
Cache layers
| Layer | Where | Holds | Invalidated by |
|---|---|---|---|
| LRU | per pod, memory | recent rendered/data entries | tag propagation / own TTL |
| Dragonfly | in-kube, shared | rendered pages, data, tag-stale state | revalidateTag('cms:id','max') |
| CMS | CMS Redis (VM) | cms.content.{id}.v{gen} | observer forget + version bump |
| ISR | time | revalidate:600 backstop | 10-min net for a lost webhook |
Environment & secrets
| Var | Side | Purpose / Vault |
|---|---|---|
| CMS_REVALIDATE_SECRET | both | webhook bearer · lei-front-next/cms-{env} |
| CMS_CONTENT_TOKEN | both | content-fetch bearer · same bundle |
| CMS_API_BASE_URL | next | pod→CMS fetch · cms.balticlei.ee / cms.dev… |
| REDIS_URL | next | Dragonfly · lei-front-next/redis-{env} · single URL |
| REVALIDATION_ENABLED | cms | kill switch |
| CMS_BASE_URL | next | existing — image proxy, unchanged |
Failure modes
| If… | Then | Bound |
|---|---|---|
| webhook lost | tag never marked stale | ISR revalidate:600 within 10 min |
| Dragonfly down | LRU only; no cross-pod propagation | degraded · Sentry alert (D29) |
| CMS down on regen | regen fails, stale keeps serving | job retries 5×; SWR hides it |
| NULL-row save | all 26 stale at once | ≤26 × single-flight, not ×pods |
| two rapid saves | second re-marks a fresh tag | idempotent — at worst one extra regen |
9Decision log
Why each call. The full record (with reversal history) is in DECISIONS.md.
Design principles
- Config-driven. Code shouldn't know which fields/blocks/domains exist — iterate
config('blocks.groups'),getFillable(), query DomainLanguage. - Idempotent + retry-safe. Webhooks may arrive more than once;
revalidateTagis naturally idempotent. - Lazy where possible. Signal → flag → lazy regen. Don't pre-render unless evidence shows the lazy path hurts UX.
- Kill switches over rollbacks. Minimal pre-launch (one flag).
- Defer until measured. No caches/locks/coordination until evidence shows they're needed.
- Invalidation granularity = fetch granularity. Tags no finer than the URL granularity. NULL = the global scope by data definition.
Locked decisions
No content in the webhook; Next pulls fresh lazily (SWR). Kairo's design center.
All pods see the same entries + tag-staleness state. Multi-pod consistency without per-pod fanout.
Stay off cacheComponents/'use cache' (#152). node-redis not ioredis; Node ≥ 22; flush Dragonfly on the major bump.
revalidateTag(tag,'max') — 2-arg form mandatory in 16.'max' gives SWR semantics; single-arg is a deprecated blocking expire.
Pages tag ['cms','cms:'+id]; global cms is the broadcast channel for default-row changes.
getAllContent() is ~7,280 block queries at prod scale; the hot path should be ~1/26 that. Bulk endpoint stays for warmup/ops.
'cms' + (domain ? ':'+domain : '').Single code path; NULL → global cms. Rejected field-aware enumeration (couples to field schema, buys nothing on whole-page cache).
revalidateTag is idempotent → one webhook per save beats 30 lines of lock+race for ~9 saved calls/burst. (reverses Kairo's 5s lock, R10)
updated only.On Block*Translation + BlogPost; no-op when no fillable field changed. ensureDefaultRows() fires on every Filament serve → observing saving/created would storm.
revalidate: 600.10-min safety net if a webhook is ever lost.
REVALIDATION_ENABLED=false → observer no-ops. Defensive switches deferred post-launch.
CMS_REVALIDATE_SECRET + CMS_CONTENT_TOKEN bundle into cms-{env}; REDIS_URL in redis-{env}. Refresh 5m, quarterly rotation.
CMS_API_BASE_URL separate from CMS_BASE_URL.New var for pod→CMS content fetch (prod cms.balticlei.ee / staging cms.dev.balticlei.ee); existing one stays for the image proxy.
Pods are identical, host→domain per request, one helm release. Shared cache is genuinely load-bearing; concurrent-regen race is real. Spike validates fleet-wide single-flight before any code.
No bulk-import path exists today; revisit if a Filament importer appears.
single_blog_post_page declared twice — separate ticket, out of scope (could change /api/content shape).
getCMSData() uses unstable_cache.4 of 5 force-dynamic pages render CMS content; force-dynamic overrides fetch cache options, so a function-level cache is required. Fortedigital persists unstable_cache entries to Dragonfly.
Shared FE Dragonfly + VM-local CMS Redis (content cache + Horizon queue). FE cache shields CMS from per-pod fanout; CMS cache shields the DB.
Fortedigital + Dragonfly is the L1; adding Cloudflare later is a one-line purge extension.
Not a stated requirement.
One CMS hit per domain per invalidation (data cache shared across pages). Erki adds per-domain uptime probes before launch.
No dynamicParams flip (already gone on develop). Add notFound() data-guards to all three (legal_disclosure today only guards a missing slug → 500 on a missing block).
Multi-tenant pods → any hostname is equivalent; one URL in CMS config.
getDomainTranslation.ts → getCMSData.ts. getCMSData(domainId) returns {sites:{[id]:…}} so all 43 sites[id] callers compile unchanged — not a 43-file refactor.
A domain=null save forgets every per-domain key (bounded ~26); a non-null save forgets just one.
Forget BE cache before dispatching → CMS rebuilds from DB on regen, no stale read. tries=5, backoff.
Composite handler falls back to per-pod LRU; revalidateTag stops propagating cross-pod (silent). Sentry alerts on Redis errors.
Actually a runtime-warmup dependency, not CI build (generateStaticParams returns []; warmup hits a running server).
createClient.HA single-instance, not sharded; single REDIS_URL, no cluster-mode branching.
No worker exists today; jobs would sit in DB forever. (Original supervisord plan replaced by Horizon.)
Redis-backed, mirrors the house exemplar spain-re-scraper; systemd + Redis install both provisioned via the C5 Ansible MR (no manual apt on prod).
unstable_cache, not 'use cache'.Fortedigital 3.2 doesn't persist 'use cache' entries (#152 open) → would break cross-pod caching. cacheComponents also disables revalidate/dynamicParams and keeps route cache on disk (breaks multi-pod). Revisit only if all of: it solves multi-pod, we drop revalidate/dynamicParams, a maintained Redis impl ships.
domain=NULL) stays.Structurally real (ensureDefaultRows seeds null rows; inheritance). D8/D27 are correct & necessary. Legal pages are the per-jurisdiction exception, handled at the FE.
domainId = underscored on both sides (corrected — was "CRITICAL").The "CMS stores dotted" premise was wrong (an over-inference from a defensive str_replace): the CMS DB stores domains underscored (balticlei_ee) — verified vs api-output.json + no dotted literal anywhere. Both sides were always underscored; there was never a tag mismatch. domainKey() stays as a harmless no-op guard — downgraded from CRITICAL. Still valid (Next): B7/B8 validate against the .domainId VALUE set (isDomainId), not the host-derived DOMAINS_CONFIG keys — key-vs-value, orthogonal.
cms.content.{id}.v{gen} with a monotonic Cache::increment gen, bumped after-commit-before-forget (D28). A slow regen can't write pre-save data under a fresh TTL. Trade-off: every save is a guaranteed miss → NULL save = 26 cold rebuilds, so stagger the loop.
Cache + queue share one Redis; allkeys-lru eviction crosses DB indexes and silently deletes queued jobs → "at-least-once" becomes at-most-once. Fix: maxmemory-policy noeviction, appendonly yes, discrete REDIS_DB (not REDIS_URL).
REVALIDATION_ENABLED gates only CMS→Next webhooks, not Next→CMS fetches. Deploy CMS first (hard gate) + a Next-side bundled-cms.json fallback for the cutover window.
We need a Redis-backed handler (hard requirement, not optional). Fortedigital (default, conservative) vs nextjs-turbo-redis-cache (challenger — <100ms Pub/Sub propagation + batch invalidation, addresses our #1 risk). Decide on Slice-0 measurements; Fortedigital wins ties. neshca excluded (React 19/Next 16 incompatible). #152 doesn't block us — we ship the legacy unstable_cache path either way.
Reversed / superseded
R1–R12 cleared earlier drafts (per-domain tags, single-tag day-1, dedicated cache stores, three kill switches, debounce-by-marker, per-domain webhook URLs…). R13: supervisord queue:work → Horizon (D35). R14: the 'use cache' flip → rejected, unstable_cache stands (D36).
10Open items
- No debounce (vs the 5s Redis lock) — idempotency makes coalescing unnecessary at our scale (D9).
- Forget in the observer, not the job — collapses the stale-read window to microseconds (D28).
- Per-domain endpoint day one — 1/26 fetch cost, real per-domain tags (D6).
+ the global-default reading: it's real & needed (D37), legal pages excepted.
Review/merge the C5 Ansible MR (Horizon systemd + Redis install — A6's worker can't run without it; Steven authors it off the spain-re-scraper exemplar) · provision FE Dragonfly + create the Vault entries · confirm the Next-chart externalSecrets remoteRef + envSuffix.
Next-side read of the getCMSData envelope shape + legal-page guards. Kert's review already landed and is folded in.
Steven creates the Asana epic anchor (paste-ready breakdown exists).
CMS Redis eviction can silently drop queued jobs (D40) — must ship noeviction + AOF in the C5 Ansible MR, or revalidation is at-most-once. And mind the cutover order (D41): deploy CMS before Next, with a bundled-JSON fallback for the window.
Both local checkouts were stale (blei-next 108 commits behind; blei-cms on a feature branch) — teams must work from fresh clones. Every "verified" claim was re-checked against the remote tips on 2026-06-03.