Skip to content
bflo.sh

ADR-0016: Event-driven on-demand revalidation via a backend relay and a signed frontend webhook

ADR-0016 — Accepted 2026-07-07. Part of the platform's decision record.

Adapted from the repo's docs/adr/0016-event-driven-on-demand-revalidation.md — Part of Internals: the platform's design set, published as content on the platform it describes.

  • Status: Accepted
  • Date: 2026-07-07
  • Deciders: contract-owner, lead
  • Tiers affected: backend (relay consumer), frontend (webhook receiver + cache tags), infra (env plumbing), contract
  • Related: ADR-0008 (the content.article-published event this ADR finally gives a consumer), ADR-0005 (file save → reindex is the only write path), ADR-0006 (the public /v1 surface stays GET-only — the webhook is out-of-band, not a public resource), ADR-0009 (the minute reindex tick), contract v0.8.0 (webhooks.contentChanged, relay receive operations)

Context

The blueprint prescribes "ISR + event-driven on-demand revalidation driven by content.article-published". Since v0.2.0 the backend has emitted two events post-commit onto Redis Streams via the publisher abstraction — content.article-published per newly-public article and content.reindexed per run (the minute scheduler tick reindexes) — but they have had zero consumers. Freshness on the reader site therefore rests entirely on the frontend's ISR windows (revalidate=300 on pages, 3600 on feed/sitemap), and in practice the user busts them by restarting the frontend container — the exact failure the events were designed to prevent.

We must close the loop: file save → reindex tick (≤1 min) → events → something → frontend caches invalidated → next request fresh. Target: typically <60 s, worst ~90 s, zero container restarts. The decision shapes the contract (the only coupling point), both tiers' implementations, and the deployment configuration, so it is recorded before anyone implements.

Decision

Contract v0.8.0 publishes the loop as two coupled interfaces; ISR stays unchanged as the safety net.

  1. A backend relay is the events' first consumer. A content:relay-events command, scheduled IMMEDIATELY after the reindex tick in the same schedule:run, reads BOTH content streams from a persisted per-stream cursor (last-delivered event id), so it resumes after downtime rather than replaying or skipping.
  2. A signed webhook is the frontend's trigger. When — and only when — the consumed events show an actual change, the relay POSTs the contentChanged webhook (OpenAPI 3.1 webhooks section) to the deployment-configured receiver URL (FRONTEND_REVALIDATE_URL; DDEV-internal default http://frontend:3000/api/revalidate — container-network HTTP, no TLS concern; production https://bflo.sh/api/revalidate). The JSON body carries contentVersion, a change summary in the event vocabulary (publishedPaths + the four reindex delta counters), and the cache-tag list.
  3. HMAC over the raw body authenticates the call. Header X-Content-Signature: sha256=<hex> = lowercase-hex HMAC-SHA256 of the RAW request body keyed with the shared REVALIDATE_SECRET. The receiver verifies over the raw bytes BEFORE parsing, with a constant-time comparison; missing/invalid → 401 problem-style JSON and no invalidation; success → 200.
  4. Exactly two broad cache tags to start. content (articles, sections, tags, authors, search — and the feed/sitemap route handlers bust via the same tags on their internal fetches) and site (getSiteMeta). The receiver calls revalidateTag per received tag. Tag values are a contract enum: adding one is a contract change.
  5. Change detection, not tick forwarding. content.reindexed fires EVERY tick, so the relay webhooks only when something changed: any content.article-published event, or a reindexed payload with articlesUpserted + articlesPruned + sectionsUpserted + sectionsPruned > 0, or a contentVersion change. No-op ticks send NOTHING. (v0.8.0 adds the four delta counters to the event payload — they existed internally but were not emitted, and the rule cannot work without them.)
  6. At-least-once delivery, idempotent receiver. The cursor advances only AFTER a successful webhook POST; a crash between POST and cursor write re-delivers, and revalidateTag is idempotent, so duplicates are harmless. Webhook failure (frontend down) is logged and NEVER fatal — it must not break or delay reindexing; the next run retries from the same cursor.
  7. ISR windows stay unchanged. revalidate=300 / 3600 remain as the safety net for the case where the relay or webhook is down long enough to matter.

Alternatives considered

  1. Frontend consumes Redis Streams directly — no webhook hop. Rejected: couples the frontend to the backend's transport and framework key-prefixing (a frontend → backend sideways edge the tier rules forbid); the contract boundary is where tiers meet, so the interface must be published there — as an HTTP webhook the frontend can implement from the OpenAPI document alone.
  2. Shorten the ISR windows (e.g. revalidate=30) — no new moving parts. Rejected: trades constant re-render load for still-stale worst cases, and does nothing for the feed/sitemap hour window; the events already exist precisely to make freshness event-driven rather than polled.
  3. Per-path precise invalidation (tag-per-article, path lists in the webhook, or revalidatePath). Rejected as the starting point: at this content scale a full content-tag bust re-renders a handful of pages, while per-path precision multiplies tag bookkeeping across pages, listings, prev/next chains, search, feed, and sitemap — every one of which can change when a single article does. Broad-two-tag invalidation is the deliberate v1; precision is a later refinement once scale demands it, and the payload already carries publishedPaths so a future receiver can refine without a payload change.
  4. Reindex tick POSTs the webhook inline (no relay/no streams) — simplest loop. Rejected: entangles reindexing with frontend availability (a down frontend would slow or fail the tick unless carefully isolated), bypasses the published events (leaving them consumer-less forever), and loses the durable resume-after-downtime cursor that streams give for free.

Consequences

  • Easier: content edits surface in <60 s typical / ~90 s worst with zero container restarts; the ADR-0008 events finally earn their keep; future consumers (cache warmers, search pings) follow the same relay pattern with their own cursor.
  • Harder: two new moving parts (relay command, webhook receiver) and a shared secret to provision in both tiers (REVALIDATE_SECRET) plus a backend-side target URL (FRONTEND_REVALIDATE_URL). A failed webhook is only logged — operators must watch the log line, since ISR quietly papers over a dead loop for up to the window length.
  • Coarseness accepted: any change busts all content pages' cache. At current scale this is the right trade (see alternative 3); revisit when re-render cost is measurable.
  • Event payload obligation: the reindexed event now carries the four delta counters as REQUIRED fields — safe today because the relay is the first-ever consumer and the only producer updates in the same track (recorded in the v0.8.0 changelog).
  • Implementation gotcha (backend): the streams live under the framework's Redis key prefix — the relay must read through the SAME Laravel Redis connection abstractions the publisher uses, never hand-built key names.

Compliance

  • Contract: webhooks.contentChanged, contentSignature, the three schemas, and the two relay receive operations exist in contract v0.8.0; ddev contract-lint clean; oasdiff breaking --flatten-allof 0.7.0 → 0.8.0 reports no breaking changes.
  • Backend: relay tests cover cursor resume, the no-op-tick rule (no POST), signature construction over raw bytes, and failure isolation (webhook down ≠ reindex failure) — in ddev composer verify.
  • Frontend: receiver tests cover constant-time verify-before-parse, 401 on bad/missing signature with no invalidation, 200 + revalidateTag per tag — in ddev frontend npm run verify.
  • End-to-end (lead): edit a live content file → page fresh within ~90 s with no container restart; ISR windows verified unchanged.