Skip to content
bflo.sh

ADR-0017: Full-content RSS 2.0 feed as a frontend-derived artifact; api limiter raised to 120 rpm

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

Adapted from the repo's docs/adr/0017-feed-enrichment-full-content-rss.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 (user-confirmed policy)
  • Tiers affected: frontend (feed route handler), backend (named api rate limiter only)
  • Related: ADR-0006 (unauthenticated, rate-limited public surface — the 60 rpm number this ADR revises sits under that posture), ADR-0007 (the sanitised HTML the feed embeds), ADR-0010 (absolute URLs span the client/API host split), ADR-0011 (in-body asset URLs are already absolute on the api host), ADR-0012 (why Mermaid degrades in feed readers), ADR-0016 (the content cache tag that keeps the feed fresh), contract v0.8.0 (unchanged by this track)

Context

Since Phase 3 the frontend has served a minimal RSS feed: summary-only items with a relative-flavoured identity (guid was the bare article path). Track 4e (blueprint §11, "feed enrichment") upgrades it to a full-content feed that reads well inside feed readers, not just as a notification stream.

Two questions shape the record: (a) is the enriched feed a new cross-team interface — i.e. does it belong in contract/? — and (b) what does full-content rendering do to the API rate budget? Rendering ~20 full items fans out to ~20 getArticle calls per feed build; combined with next build static generation, this repeatedly tripped the 60 rpm per-IP api limiter ratified in Phase 2 (recurring 429s observed in this session).

Decision

  1. The feed is a frontend-derived artifact — NO contract change; v0.8.0 stands. The feed is composed entirely from already-published operations: listArticles (item set), getArticle (full bodies), getSiteMeta (channel identity). The team-to-team coupling point remains exactly those operations; RSS is a presentation of them, owned by frontend-engineer, exactly like the HTML pages built from the same calls.
  2. RSS 2.0 only. Atom, JSON Feed, and per-section/per-tag feeds are explicit non-goals this track.
  3. Full-content items. Each item carries content:encoded with the rendered sanitised HTML body in CDATA — guarding against ]]> in content by splitting the CDATA section — alongside the existing escaped-summary <description>.
  4. Item metadata set: dc:creator per author (display name, one element per author); <category> per tag; absolute <link>; guid = the absolute article URL with isPermaLink="true" (changed from the bare path — correct RSS semantics; the one-time re-delivery of all items in already-subscribed readers is accepted pre-launch); pubDate in RFC-822 format.
  5. Channel additions: <atom:link rel="self" type="application/rss+xml"> and <lastBuildDate>; channel title/description stay getSiteMeta-sourced. The rss element declares xmlns:content, xmlns:dc, xmlns:atom.
  6. Mermaid fences degrade to the visible source code block in feed bodies — feed readers cannot run the client render island (ADR-0012), so the inert fence shows as code. Documented behaviour, not fought. In-body asset URLs are already absolute on the api host (ADR-0011) — verified, not rewritten.
  7. Derivation keeps the existing exclusions and freshness: unlisted articles never appear (they are absent from listArticles); the feed route keeps revalidate=3600 plus the content cache tag, so the ADR-0016 loop busts it on content changes.
  8. The named api limiter rises 60 → 120 requests/min per IP (RateLimiter::for('api', …) in backend/routes/api.php). This revises the Phase-2-ratified 60 rpm: a full-content feed render (~20 getArticle fetches) plus next build static generation kept exceeding the old ceiling. The separate 300 rpm assets limiter (ADR-0011) is untouched. The surface stays rate-limited per ADR-0006 — only the number changes.

Alternatives considered

  1. Publish the feed in the contract (an OpenAPI path or documented artifact). Rejected: the contract governs interfaces between tiers; the feed is served by the frontend to end readers and consumes only published operations. Adding it would make the contract describe a consumer's output, inverting the tier direction.
  2. Backend-rendered feed endpoint (GET /v1/feed). Rejected: it would push presentation (site URLs, channel identity, reader-facing formatting) into the backend and would be a contract change — all for an artifact the frontend can already derive.
  3. Summary-only items with more metadata (no content:encoded). Rejected: the point of the track is reading in the reader; metadata alone does not deliver that.
  4. Keep guid as the bare path. Rejected: guid without isPermaLink="true" and without an absolute URL is semantically wrong for permalink-identified items; fixing it pre-launch costs only one accepted re-delivery.
  5. Per-route limiter exemption for feed-building fetches instead of raising the global number. Rejected: the feed's fetches are ordinary getArticle calls from the same client IP as build-time generation; special-casing them would complicate the limiter for no consumer-visible benefit, while 120 rpm remains a conservative ceiling for a read-only cached API.

Consequences

  • Easier: the feed is a first-class reading surface; freshness rides the existing ADR-0016 loop; no new contract version, no client regeneration, no backend endpoint.
  • Accepted one-time cost: subscribed readers re-deliver all items once when guids switch from paths to absolute URLs (pre-launch, so effectively zero audience).
  • Known degradation: Mermaid diagrams appear as source code blocks in readers.
  • Rate-budget revision: 120 rpm per IP supersedes the Phase-2 number; any future capacity reasoning starts from 120. The assets limiter is unaffected.
  • Copy drift to fix: docs/ui-ux.md user-facing copy (shell and search 429 messages) still says "60 requests a minute" — it must be updated to 120 by frontend-engineer (docs + any shipped strings) in this track's integration.
  • Non-goals stand: requests for Atom/JSON Feed/scoped feeds are new decisions, not extensions of this one.

Compliance

  • Contract: no spec files change; ddev contract-lint remains clean; info.version stays 0.8.0. A docs-only note in contract/CHANGELOG.md records the no-change decision.
  • Frontend: feed route tests cover CDATA ]]> splitting, guid/link absoluteness, RFC-822 dates, per-author dc:creator, per-tag <category>, self-link and lastBuildDate, and exclusion of unlisted articles — in ddev frontend npm run verify.
  • Backend: the limiter change is confined to the named api limiter definition — in ddev composer verify.
  • End-to-end (lead): real-HTTP feed acceptance — fetch the live feed, validate it, confirm full bodies with absolute asset URLs, and confirm feed generation no longer 429s under build + render load.