Implementation plan · working doc for review

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.

v0.11Owner StevenSponsor Kairo blei-cms → mainblei-next → developDecisions D1–D42

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.

TodayAfter 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

webhook · bearer Editor savesObserverHorizon job/api/revalidateLazy regen Filament · afterCommitforget + queuePOST webhookrevalidateTag('cms:id')serve stale → fresh 1234 save{domainId}tag → stalenext request
Step 4's regen pulls one domain back from CMS (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.

blei-cms · VM Kubernetes Filament editorObserver (afterCommit)Horizon · queue + workerGET /api/content/{id} CMS Redis · content + queue forgetqueueremember Ingress · host→domainNext podNext podNext podUsers · 26 hosts Dragonflyshared FE cache + tags all pods share one cache POST /api/revalidate {domainId}GET /api/content/{id} (regen)
Solid purple = in-CMS writes (forget, queue, remember). Solid grey across the gap = the webhook. Dashed teal = a pod pulling fresh content on regen.

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

User requestPod LRUfresh?Dragonflyfresh?Serve stale+ bg regenServe fastRebuildCMS Redis / DB serve (LRU)serve (shared) miss/stalestalehithitfresh later →GET /content/{id}
LRU (per-pod) → Dragonfly (shared) → CMS. On a stale tag the pod serves old HTML instantly, regenerates in the background, and writes fresh to Dragonfly. The version key (D39) means a slow regen can't overwrite newer data.

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

default row · domain = NULLauthored once, per language balticlei_eefrancelei_frindialei_in inherits defaultoverrides 1 fieldinherits default
NULL = global scope by data definition. Exception: legal pages (Impressum / privacy / ToS) are per-jurisdiction with no meaningful default → guarded with 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

#TaskRefs
A1Wire 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
A2Create 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
A3Add 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
A4RevalidationDispatcher service. ->dispatchFor($model) resolves domain from $model->block?->domain or $model->domain, converts to underscored (D38), then dispatches RevalidateFrontendJob.D8·D10·D38
A5ContentRevalidationObserver 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
A6RevalidateFrontendJob: 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
A7Feature 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
A8php artisan cms:revalidate {domain?} — ops escape hatch (global or per-domain).
A9REVALIDATION_ENABLED env flag — observer no-ops when false (kill switch / deploy ordering).D12

Bblei-next — Next 16 App Router → develop

#TaskRefs
B1Bump 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
B2cache-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
B3next.config.ts: cacheHandler: require.resolve('./cache-handler.mjs'), cacheMaxMemorySize: 0. Verify Turbopack compat (turbopack.root is unusual — smoke test).D2
B4Edit existing instrumentation.ts (already has Sentry register): add registerInitialCache({ setOnlyIfNotExists: true }) inside register() so rolling deploy doesn't clobber warm cache.D2
B5Rename 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
B6Rewrite 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
B7Add getAllCMSData(){ sites:{…all…} } (loops DOMAINS_CONFIG). Only confirmed caller: scripts/warmup.ts (legal_disclosure has no generateStaticParams — see B14).D6
B8app/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
B9Vitest 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.
B10Data 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
B11Env 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/redislei-front-next/*-{env}) AND append both to deployment.envFrom: secretRef — externalSecrets only creates the secrets; envFrom injects them.D13·D14
B12Remove bundled cms.json runtime path (keep file as test fixture).
B13Bump replicaCount after Slice 2 verifies shared cache (value per Erki).
B14CMS 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

#TaskRefs
C1Provision FE Dragonfly (master-replica) in-kube (K8s reference repo: baltic-lei/infrastructure/kube-lab) + Vault entries for the new secrets.D2·D13·D14
C2Observability: 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
C3Publish the multi-pod verification runbook (§7).
C4Cloudflare integration — deferred, out of scope.D21
C5Ansible 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.

SLICE 0Cache-handler spikegate · runs first

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

SLICE 1Foundations

B1B2B3B4A1B5

✓ Verifiable: two Next instances locally share rendered pages via Redis.

SLICE 2Live data + pushhuman sign-off

A2A3B6B7B11B12A4A5A6A7A9B8B9A8

✓ Verifiable end-to-end: edit in Filament → fresh content on Next within seconds.

SLICE 3Hardening

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.

MRLaneSlices (loops inside)Dep
M0scratchCache spike + Forte-vs-turbo bake-off → decision (no MR)GATE
M1nextNext foundations: bump (B1) · handler+config (B2,B3) · instrumentation+rename (B4,B5)after M0
M2cmsCMS content API: Redis/Horizon+DomainKey (A1) · endpoint (A3) · bearer+version-cache (A2)‖ M1
M3acmsCMS dispatch: job (A6) · dispatcher+kill-switch (A4,A9)after M2 · ‖ M4a
M3bcmsCMS trigger: observer+repo (A5) · e2e tests+command (A7,A8)after M3a
M4anextNext live fetch: getCMSData (B6) · getAllCMSData+migration (B7)after M1 · ‖ M3a
M4bnextNext push+wiring: /api/revalidate+tests (B8,B9) · env/helm+drop cms.json (B11,B12)after M4a
M5bothHardening: legal guards (B10) · replicas+dashboards+runbook (B13,C2,C3)after Sync B
InfraErkiC1 Dragonfly+Vault · C5 Horizon ansibleC5 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).

Per-milestone review gate — tech-lead-owned (your "directly tested + holistic review" requirement)

(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
Acceptance criteria
  • 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

LayerWhereHoldsInvalidated by
LRUper pod, memoryrecent rendered/data entriestag propagation / own TTL
Dragonflyin-kube, sharedrendered pages, data, tag-stale staterevalidateTag('cms:id','max')
CMSCMS Redis (VM)cms.content.{id}.v{gen}observer forget + version bump
ISRtimerevalidate:600 backstop10-min net for a lost webhook

Environment & secrets

VarSidePurpose / Vault
CMS_REVALIDATE_SECRETbothwebhook bearer · lei-front-next/cms-{env}
CMS_CONTENT_TOKENbothcontent-fetch bearer · same bundle
CMS_API_BASE_URLnextpod→CMS fetch · cms.balticlei.ee / cms.dev…
REDIS_URLnextDragonfly · lei-front-next/redis-{env} · single URL
REVALIDATION_ENABLEDcmskill switch
CMS_BASE_URLnextexisting — image proxy, unchanged

Failure modes

If…ThenBound
webhook losttag never marked staleISR revalidate:600 within 10 min
Dragonfly downLRU only; no cross-pod propagationdegraded · Sentry alert (D29)
CMS down on regenregen fails, stale keeps servingjob retries 5×; SWR hides it
NULL-row saveall 26 stale at once≤26 × single-flight, not ×pods
two rapid savessecond re-marks a fresh tagidempotent — at worst one extra regen

9Decision log

Why each call. The full record (with reversal history) is in DECISIONS.md.

Design principles

  1. Config-driven. Code shouldn't know which fields/blocks/domains exist — iterate config('blocks.groups'), getFillable(), query DomainLanguage.
  2. Idempotent + retry-safe. Webhooks may arrive more than once; revalidateTag is naturally idempotent.
  3. Lazy where possible. Signal → flag → lazy regen. Don't pre-render unless evidence shows the lazy path hurts UX.
  4. Kill switches over rollbacks. Minimal pre-launch (one flag).
  5. Defer until measured. No caches/locks/coordination until evidence shows they're needed.
  6. Invalidation granularity = fetch granularity. Tags no finer than the URL granularity. NULL = the global scope by data definition.

Locked decisions

D1 Webhook is invalidation-only.

No content in the webhook; Next pulls fresh lazily (SWR). Kairo's design center.

D2 Shared FE Redis via Fortedigital cache-handler.

All pods see the same entries + tag-staleness state. Multi-pod consistency without per-pod fanout.

D3 Next 16.x + Fortedigital @^3.2.

Stay off cacheComponents/'use cache' (#152). node-redis not ioredis; Node ≥ 22; flush Dragonfly on the major bump.

D4 revalidateTag(tag,'max') — 2-arg form mandatory in 16.

'max' gives SWR semantics; single-arg is a deprecated blocking expire.

D5 Per-domain tag scheme.

Pages tag ['cms','cms:'+id]; global cms is the broadcast channel for default-row changes.

D6 Per-domain CMS endpoint day one.

getAllContent() is ~7,280 block queries at prod scale; the hot path should be ~1/26 that. Bulk endpoint stays for warmup/ops.

D8 Tag = 'cms' + (domain ? ':'+domain : '').

Single code path; NULL → global cms. Rejected field-aware enumeration (couples to field schema, buys nothing on whole-page cache).

D9 No debounce.

revalidateTag is idempotent → one webhook per save beats 30 lines of lock+race for ~9 saved calls/burst. (reverses Kairo's 5s lock, R10)

D10 Observer scope: updated only.

On Block*Translation + BlogPost; no-op when no fillable field changed. ensureDefaultRows() fires on every Filament serve → observing saving/created would storm.

D11 ISR backstop revalidate: 600.

10-min safety net if a webhook is ever lost.

D12 One kill switch.

REVALIDATION_ENABLED=false → observer no-ops. Defensive switches deferred post-launch.

D13 Bearer both ways + Vault bundling.

CMS_REVALIDATE_SECRET + CMS_CONTENT_TOKEN bundle into cms-{env}; REDIS_URL in redis-{env}. Refresh 5m, quarterly rotation.

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

D16/D30 Slice 0 spike + multi-tenant pods.

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.

D17 Editors edit one domain at a time.

No bulk-import path exists today; revisit if a Filament importer appears.

D18 Pre-existing blocks.php duplicate-key bug.

single_blog_post_page declared twice — separate ticket, out of scope (could change /api/content shape).

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

D20 Two-cache architecture.

Shared FE Dragonfly + VM-local CMS Redis (content cache + Horizon queue). FE cache shields CMS from per-pod fanout; CMS cache shields the DB.

D21 No Cloudflare HTML caching in scope.

Fortedigital + Dragonfly is the L1; adding Cloudflare later is a one-line purge extension.

D22 No audit logging.

Not a stated requirement.

D23/D33 No warmup automation.

One CMS hit per domain per invalidation (data cache shared across pages). Erki adds per-domain uptime probes before launch.

D24 Legal pages: guards only.

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

D25 Single canonical webhook URL.

Multi-tenant pods → any hostname is equivalent; one URL in CMS config.

D26 Rename + envelope shape.

getDomainTranslation.ts → getCMSData.ts. getCMSData(domainId) returns {sites:{[id]:…}} so all 43 sites[id] callers compile unchanged — not a 43-file refactor.

D27 NULL-row forget loop.

A domain=null save forgets every per-domain key (bounded ~26); a non-null save forgets just one.

D28 Save-flow ordering.

Forget BE cache before dispatching → CMS rebuilds from DB on regen, no stale read. tries=5, backoff.

D29 Redis-down behavior.

Composite handler falls back to per-pod LRU; revalidateTag stops propagating cross-pod (silent). Sentry alerts on Redis errors.

D31 Build-time CMS dependency — corrected.

Actually a runtime-warmup dependency, not CI build (generateStaticParams returns []; warmup hits a running server).

D32 Dragonfly master-replica → createClient.

HA single-instance, not sharded; single REDIS_URL, no cluster-mode branching.

D34 Queue worker prerequisite — superseded by D35.

No worker exists today; jobs would sit in DB forever. (Original supervisord plan replaced by Horizon.)

D35 Queue = Laravel Horizon (Erki).

Redis-backed, mirrors the house exemplar spain-re-scraper; systemd + Redis install both provisioned via the C5 Ansible MR (no manual apt on prod).

D36 REJECTED — stay on 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.

D37 Global default (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.

D38 Canonical 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.

D39 Version-keyed BE cache (CRITICAL).

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.

D40 CMS Redis durability — noeviction + AOF (CRITICAL).

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

D41 Cutover & deploy ordering.

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.

D42 Cache handler = Slice-0 bake-off.

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

Confirm with Kairo — 3 divergences from the diagram
  • 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.

Needs Erki

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.

Needs Yev (when back) · not blocking

Next-side read of the getCMSData envelope shape + legal-page guards. Kert's review already landed and is folded in.

Pending — P9

Steven creates the Asana epic anchor (paste-ready breakdown exists).

Top operational risk (review round 2)

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.

Before the build teams start

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.