Adapted from the repo's docs/adr/0013-authors-are-free-label-strings.md — Part of Internals: the platform's design set, published as content on the platform it describes.
- Status: Accepted
- Date: 2026-07-06
- Deciders: contract-owner (with the lead; resolves master-blueprint open-question #4)
- Tiers affected: contract | backend | frontend | docs
- Related: ADR-0005 (filesystem is the content source of truth), ADR-0006 (read-only
unauthenticated public API),
docs/master-blueprint.md§11 (Track 4b), contract v0.5.0
Context
Track 4b adds author browsing: an author catalogue (GET /authors) and per-author article
listings (GET /authors/{author}), plus the already-published author query filter on
GET /articles. This forces the long-deferred blueprint open-question #4 — what is an
author? — to be decided now, because the answer determines the wire shape of the Author
schema and the semantics of every author-addressed endpoint, and those cannot be published
half-defined.
The forces bounding the options:
- Content is filesystem-authored, out of band (ADR-0005). Each article's front matter
already carries
authors[]as a list of display strings; there is no author registry, no user table, and no identity system anywhere in the stack. - The public API is read-only and unauthenticated (ADR-0006): there is no sign-in, no profile owner, and no write path by which an author record could be created or curated.
- This is a personal publishing platform. In practice the author set is small and authored by hand; a full identity model (accounts, bios, avatars, canonical IDs) would be machinery with no current consumer.
- Contract-first: the
Authorshape and endpoint semantics must be settled and versioned before any tier implements against them.
Decision
We will treat an author as a free-label display string, not an account or a first-class
identity. There is no author registry, no authors.json, no bios, avatars, or stable author
IDs. The catalogue is derived by aggregating the free-label authors[] strings across
published articles.
Concretely, and mirroring the shipped tags feature exactly:
GET /articles?author=(unchanged),GET /authors, andGET /authors/{author}all operate on the free-label author string. The label is slugified for URLs and filters; theAuthorschema carries bothslug(URL/filter form) andname(the display label, as authored) — the same{slug, name, count}shape asTag's{slug, label, count}, substitutingnameforlabel.- An unknown author slug is not a 404:
GET /authors/{author}returns an empty 200 page, identical toGET /tags/{tag}on an unknown tag. The endpoint filters a listing; it does not resolve an entity whose absence is an error. - The slug is server-published on every byline, not derived by the client. Article
bylines carry an
AuthorRef { slug, name }(contract v0.5.0), not a bare label:ArticleSummary.authorsis an array ofAuthorRef. Theslugis produced by one canonical derivation — a single author slugify (kebab-case, mirroring the tag slug rule) — that is the sole source of author slugs everywhere: on byline refs, in theAuthorcatalogue, and in the stored filter slugs. A byline'sslugtherefore always equals its matching catalogue item'sslug. - The by-author filter and path/query params are slug-based. Both
GET /authors/{author}andGET /articles?author=take the author slug (not the raw label), and the server matches it against a stored, derived per-article author-slug list, not against the raw label strings. This fixes the by-author-returns-0 defect: previously the client guessed the slug from the label and the index filtered that guessed slug against raw labels, so the match never landed. Free labels remain the authored source of truth; the slug is a server-derived, deterministic projection of the label — no author registry or identity is introduced.
Introducing real author identity later (accounts, bios, canonical IDs, profile pages) would be a future contract change with its own ADR — superseding this one — not an in-place tweak.
Alternatives considered
- Author registry /
authors.jsonwith bios and stable IDs — pros: canonical identity, richer profile pages, disambiguation of same-named authors. Cons: introduces an identity system with no consumer today, a second source of truth alongside the filesystem content (against ADR-0005), and curation/write surface that a read-only unauthenticated API (ADR-0006) has no place to expose. Lost as premature machinery; deferrable without cost because it can be layered in later as an additive contract change. - Author as a first-class resource (
GET /authors/{author}returns an author entity, 404 on unknown) — pros: cleaner REST resource semantics. Cons: diverges from the shipped tag convention (an unknown tag is an empty page, not a 404), reintroduces the existence-disclosure concern the empty-page convention avoids, and implies an identity we explicitly do not have. Lost for inconsistency with the established filter-listing pattern. - No author browsing at all (keep only the
authorquery filter) — pros: zero new surface. Cons: leaves blueprint question #4 open and gives readers no way to discover the author set; the catalogue is cheap to derive from data already indexed. Lost as under-delivering on Track 4b.
Consequences
- Easier: author browsing reuses the tags machinery end to end — the
Authorvalue object already exists, article listing reusesListPublishedArticles+Filters::withAuthor, and the only genuinely new cross-cutting piece is theContentIndex::authorCatalogue()port method (mirroringtagCatalogue()). No identity system, migration, or write path is added. - Harder / now forbidden: there is no canonical author identity — same-named authors collapse to one label, and there are no bios/avatars/profile pages. Anything requiring stable author identity must wait for a future superseding ADR + contract change; no tier may bolt an ad-hoc author store onto this release.
- Follow-up tasks (Track 4b mirror change plus the byline-slug reshape, one owner per
tier; the lead runs full
composer verify/npm run verify/ real-HTTP at integration because the byline reshape does not compile clean until every tier lands):domain-engineer—AuthorRefvalue object holding the one canonicalAuthorRef::fromLabelslugify; reshapeArticleSummary.authorstoAuthorRef[](propagates toArticle/SearchResult);ContentIndex::authorCatalogue()(usingAuthorRef::fromLabelso catalogue slug equals byline slug) +ListAuthorsaction + domain-tier fakes/factories.data-engineer— derivedauthor_slugstext-array column populated at reindex viaAuthorRef::fromLabel(label).slug; by-author filter matches the slug againstauthor_slugs(not labels);PostgresContentIndex::authorCatalogue()+ persistence-tier fakes/fixtures.backend-api—ListAuthorsController,ListArticlesByAuthorController,AuthorResource,ListArticlesByAuthorRequest,/v1routes; serialize bylines asAuthorRefobjects.frontend-engineer—/authorsand/authors/[author]routes; regenerate the client; delete the client-sideauthorSlug()guess and use the server-published byline slug.
Compliance
- The contract encodes the decision:
Authoris{slug, name, count}andAuthorRefis{slug, name}(no bio/id fields on either), andGET /authors/{author}documents the empty-200-on-unknown behaviour.ddev contract-lintmust stay clean.oasdiff breaking --flatten-allofagainst v0.4.0 now reports one breaking change —ArticleSummary.authorsitems changing fromstringto theAuthorRefobject on every article payload — which is expected and intended: it is the byline-slug fix, not smuggled identity. No bio/id/account fields were added; the only shape change is label-string →{slug, name}. (This supersedes the earlier additive-only expectation for the authors feature, which was recorded before the byline reshape folded into the unreleased v0.5.0.) - Any later move to author identity is detectable as a new
Author-shape change in the contract and must arrive as a superseding ADR — reviewed with the same scrutiny as a breaking change if it narrows or repurposes the existing free-label fields.