Skip to content
bflo.sh

ADR-0013: Authors are free-label strings, not accounts

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

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 Author shape 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, and GET /authors/{author} all operate on the free-label author string. The label is slugified for URLs and filters; the Author schema carries both slug (URL/filter form) and name (the display label, as authored) — the same {slug, name, count} shape as Tag's {slug, label, count}, substituting name for label.
  • An unknown author slug is not a 404: GET /authors/{author} returns an empty 200 page, identical to GET /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.authors is an array of AuthorRef. The slug is 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 the Author catalogue, and in the stored filter slugs. A byline's slug therefore always equals its matching catalogue item's slug.
  • The by-author filter and path/query params are slug-based. Both GET /authors/{author} and GET /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

  1. Author registry / authors.json with 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.
  2. 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.
  3. No author browsing at all (keep only the author query 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 Author value object already exists, article listing reuses ListPublishedArticles + Filters::withAuthor, and the only genuinely new cross-cutting piece is the ContentIndex::authorCatalogue() port method (mirroring tagCatalogue()). 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-engineerAuthorRef value object holding the one canonical AuthorRef::fromLabel slugify; reshape ArticleSummary.authors to AuthorRef[] (propagates to Article / SearchResult); ContentIndex::authorCatalogue() (using AuthorRef::fromLabel so catalogue slug equals byline slug) + ListAuthors action + domain-tier fakes/factories.
    • data-engineer — derived author_slugs text-array column populated at reindex via AuthorRef::fromLabel(label).slug; by-author filter matches the slug against author_slugs (not labels); PostgresContentIndex::authorCatalogue() + persistence-tier fakes/fixtures.
    • backend-apiListAuthorsController, ListArticlesByAuthorController, AuthorResource, ListArticlesByAuthorRequest, /v1 routes; serialize bylines as AuthorRef objects.
    • frontend-engineer/authors and /authors/[author] routes; regenerate the client; delete the client-side authorSlug() guess and use the server-published byline slug.

Compliance

  • The contract encodes the decision: Author is {slug, name, count} and AuthorRef is {slug, name} (no bio/id fields on either), and GET /authors/{author} documents the empty-200-on-unknown behaviour. ddev contract-lint must stay clean. oasdiff breaking --flatten-allof against v0.4.0 now reports one breaking changeArticleSummary.authors items changing from string to the AuthorRef object 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.