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
apirate 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
contentcache 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
- 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 byfrontend-engineer, exactly like the HTML pages built from the same calls. - RSS 2.0 only. Atom, JSON Feed, and per-section/per-tag feeds are explicit non-goals this track.
- Full-content items. Each item carries
content:encodedwith the rendered sanitised HTML body in CDATA — guarding against]]>in content by splitting the CDATA section — alongside the existing escaped-summary<description>. - Item metadata set:
dc:creatorper author (display name, one element per author);<category>per tag; absolute<link>;guid= the absolute article URL withisPermaLink="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);pubDatein RFC-822 format. - Channel additions:
<atom:link rel="self" type="application/rss+xml">and<lastBuildDate>; channel title/description staygetSiteMeta-sourced. Thersselement declaresxmlns:content,xmlns:dc,xmlns:atom. - 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.
- Derivation keeps the existing exclusions and freshness: unlisted articles never
appear (they are absent from
listArticles); the feed route keepsrevalidate=3600plus thecontentcache tag, so the ADR-0016 loop busts it on content changes. - The named
apilimiter rises 60 → 120 requests/min per IP (RateLimiter::for('api', …)inbackend/routes/api.php). This revises the Phase-2-ratified 60 rpm: a full-content feed render (~20getArticlefetches) plusnext buildstatic generation kept exceeding the old ceiling. The separate 300 rpmassetslimiter (ADR-0011) is untouched. The surface stays rate-limited per ADR-0006 — only the number changes.
Alternatives considered
- 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.
- 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. - 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. - Keep guid as the bare path. Rejected:
guidwithoutisPermaLink="true"and without an absolute URL is semantically wrong for permalink-identified items; fixing it pre-launch costs only one accepted re-delivery. - Per-route limiter exemption for feed-building fetches instead of raising the global
number. Rejected: the feed's fetches are ordinary
getArticlecalls 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
assetslimiter is unaffected. - Copy drift to fix:
docs/ui-ux.mduser-facing copy (shell and search 429 messages) still says "60 requests a minute" — it must be updated to 120 byfrontend-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-lintremains clean;info.versionstays 0.8.0. A docs-only note incontract/CHANGELOG.mdrecords the no-change decision. - Frontend: feed route tests cover CDATA
]]>splitting, guid/link absoluteness, RFC-822 dates, per-authordc:creator, per-tag<category>, self-link andlastBuildDate, and exclusion of unlisted articles — inddev frontend npm run verify. - Backend: the limiter change is confined to the named
apilimiter definition — inddev 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.