Adapted from the repo's docs/adr/0014-site-identity-in-root-content-metadata.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 (with the lead)
- Tiers affected: contract | backend | frontend | docs
- Related: ADR-0005 (filesystem is the content source of truth), ADR-0006 (read-only
unauthenticated public API), ADR-0009 (content sync and content root),
docs/master-blueprint.md§11 (Track 4d — revalidation, the staleness caveat below), contract v0.6.0
Context
The site's display identity — the masthead name, the homepage hero lines (thesis +
tagline/note), the RSS channel title/description, and the default meta description — is
today configured as frontend environment variables (SITE_NAME, SITE_TAGLINE,
SITE_THESIS in frontend/.env.local). That contradicts the product's core stance:
- The filesystem content tree is the single source of truth for everything the reader sees (ADR-0005). Rebranding the site should be a content edit, not an ops change to a frontend env file followed by a redeploy.
- The env vars are invisible to the backend, so the feed (served by the backend) and the frontend masthead can drift apart — two places declare "what this site is called".
- The content root already has a natural home for this: the ROOT
metadata.json(backend/storage/app/content/metadata.json), which today carries the root section'stitle/description.
The forces on the shape: the root metadata's existing top-level title/description
are the ROOT SECTION's identity and must keep meaning that; site identity is related but
not identical (a section title is not necessarily a masthead name); and the field must be
optional — an existing content tree without any site block must keep working.
Decision
We will store site identity in an OPTIONAL site object in the ROOT content metadata
file, resolve it server-side with a single canonical fallback rule, and publish it
via a new contract endpoint GET /site (operation getSiteMeta, schema SiteMeta,
contract v0.6.0). The frontend env-var display config (SITE_NAME, SITE_TAGLINE,
SITE_THESIS) is replaced and will be removed from the frontend in a follow-up task.
The root metadata shape:
{
"title": "Field Notes",
"description": "...",
"site": {
"title": "Field Notes",
"thesis": "Writing, kept like source.",
"tagline": "Notes on building things."
}
}
The existing top-level title/description remain the root section identity and double
as fallbacks.
Canonical resolution rule (one implementation): title = site.title, else root
title, else "Untitled"; tagline = site.tagline, else root description, else the
empty string; thesis = site.thesis, else null. All values are trimmed;
empty/whitespace-only strings are treated as absent. The API therefore ALWAYS returns a
non-empty title and a tagline string (possibly empty); thesis is nullable.
Where the rule lives: in the Domain tier, as a static resolving constructor on the
App\Domain\Content\ValueObject\SiteMeta value object (e.g.
SiteMeta::resolve(rawTitle, rawDescription, rawSite)). The infrastructure
SiteMetaProvider (data-engineer) reads the raw root metadata.json through the
existing filesystem source machinery and delegates resolution to that constructor —
it carries no fallback logic of its own. The Domain action
App\Domain\Content\Action\GetSiteMeta fronts the SiteMetaProvider port for the HTTP
tier. This keeps exactly one implementation of the rule, unit-testable without the
filesystem.
Field semantics (as published in the contract):
title— site display name: masthead, HTML document title, feed channel title.thesis— homepage headline line (hero statement).tagline— homepage note line, default meta description, feed channel description.
Alternatives considered
- Keep frontend env vars — pros: zero new surface. Cons: rebranding is an ops change, not a content edit (against the file-first product stance); backend-served surfaces (the feed) cannot see the values, so identity is duplicated and drifts. Lost on principle (ADR-0005).
- A separate
site.jsonat the content root — pros: clean separation from section metadata. Cons: a second root metadata file to sync, validate, and document for one small object; the rootmetadata.jsonalready exists and itstitle/descriptionare the natural fallbacks. Lost as unnecessary file surface. - Client-side fallback resolution (publish the raw
siteblock and let consumers resolve) — pros: thinner API. Cons: every consumer (frontend pages, feed builder) re-implements trim/fallback and they drift — the exact class of bug the author-slug episode (ADR-0013) taught us to avoid. Server-side single-rule resolution wins.
Consequences
- Easier: rebranding is now a single content-file edit; the feed and the frontend
masthead can never disagree (both consume the same resolved
SiteMeta); a content tree without asiteblock keeps working via the fallbacks. - Harder / caveat — ISR staleness: frontend pages consume the API through ISR with
revalidate = 300, so an identity edit becomes visible within the revalidate window (up to ~5 minutes), not instantly. This holds until the Track 4d on-demand revalidation loop lands, which will close the gap for content-driven changes generally; site identity rides that same loop. - Follow-up tasks: backend implementation per the v0.6.0 changelog "Impacted
members" (domain-engineer, data-engineer, backend-api); then a SEPARATE frontend task
to regenerate the client, consume
getSiteMetafor masthead/hero/meta/feed, and removeSITE_NAME/SITE_TAGLINE/SITE_THESISfromfrontend/.env.localand any env plumbing.
Compliance
- The contract encodes the decision:
GET /sitereturns the closedSiteMeta{ title, tagline, thesis }with the resolution rule documented on the operation and schema;ddev contract-lintstays clean;oasdiff breaking --flatten-allofv0.5.0 → v0.6.0 reports no breaking changes (purely additive). - Detectable drift: any tier re-implementing the fallback rule outside the
SiteMetaVO constructor, or the frontend reintroducing site-identity env vars, violates this ADR and is flagged in review.