# Declarative Changelogs

**v0.1 — provisional, revised October 2026.** A proposal, not a ratified format. This page is the
specification in brief, drafted and revised against a working changelog aggregator rather than in
the abstract; the [open questions](#open-questions) at the bottom are where it is most likely to
change. The October revision adds [Discovery and hosting](#discovery-and-hosting), which governs
how a document is served rather than what it says, so documents still declare `changelog: "0.1"`.

A declarative changelog can be ingested by a machine with no heuristics, no extraction, and no
language model — while still reading, to a human, like an ordinary `CHANGELOG.md`. The name states
the contract: every fact a consumer would otherwise infer, the publisher declares. Today every
publisher independently invents a format and every consumer independently reverse-engineers it;
this format exists so both can stop.

A complete conformant document is at [the reference example](/spec/example).

## Design principles

1. **A conformant document is a good `CHANGELOG.md`.** It must read as an ordinary changelog on
   GitHub, on npm, and in any Markdown viewer. Its two machine-facing constructs — the frontmatter
   and the escape hatch — render as visible blocks, and that is chosen over invisible metadata on
   purpose: visible metadata is metadata a human can watch drift.
2. **Structure comes from Markdown people already write.** Headings, links, blockquotes, lists —
   not an attribute microsyntax bolted onto them.
3. **Parsing is deterministic and bounded.** No backtracking, no ambiguity, no "usually this
   means".
4. **Absent beats guessed.** A publisher who states no version gets no version — never one
   inferred from a headline.
5. **Partial adoption pays.** Two conformance levels, and most of the value is in the first.

## Conformance language

MUST, SHOULD and MAY are RFC 2119's. Every rule binds one of three roles: the **publisher** writes
the document; a **validator** checks it against the version it declares, at authoring time,
loudly; a **consumer** ingests documents it cannot fix, and salvages. Every validation error below
is the validator's to reject and the consumer's to survive — see
[Reading a damaged document](#reading-a-damaged-document).

## The document

One Markdown file: YAML frontmatter, one `# ` title, then one `## ` heading per release in
reverse-chronological order.

````markdown
---
changelog: "0.1"
product:
  name: Kestrel
  vendor: Corvid Labs
  homepage: https://kestrel.example
document:
  updated: 2026-07-28T14:02:00Z
  coverage: complete
---

# Kestrel changelog

## [2.4.0](https://kestrel.example/releases/2.4.0) — 2026-07-09

> Task graphs run in parallel by default, cutting cold builds roughly in half.

### Added

- Tasks with no declared dependency on each other now run in parallel. ([#1204](https://github.com/corvid/kestrel/issues/1204))

### Fixed

- `kestrel watch` no longer misses edits to symlinked files.
````

### YAML, profiled

Both YAML blocks — the frontmatter and the [escape hatch](#the-escape-hatch) — are read with
YAML's **failsafe schema**: maps, sequences and strings, nothing else. A value's type comes from
this specification, never from YAML's implicit typing: `1.10` is the four characters `1.10`,
`2026-07-09` is a string, `no` is a string, and quoting is a style choice. Anchors, aliases,
custom tags, directives and multi-document streams MUST NOT appear. A key typed *list* is a YAML
sequence; *boolean* is exactly the string `true` or `false`.

### Frontmatter

`changelog` is required. Everything else is optional, but a document that omits `product.name`
cannot be attributed out of context.

| Key | Type | Notes |
| :-- | :-- | :-- |
| `changelog` | string | **Required.** The format version, `"0.1"` |
| `product.name` | string | Display name |
| `product.vendor` | string | Who publishes it |
| `product.homepage` | URL | The product, not the changelog |
| `product.id` | string | Stable identifier; defaults to `name` slugged by the fixed algorithm below |
| `product.description` | string | One line |
| `product.platforms` | list | The product's platform set — values below |
| `product.versioning` | `semver` \| `calver` \| `none` | Names the version scheme; only `semver` switches anything on |
| `product.category` | string | Advisory. Any string; consumers treat it as a hint |
| `product.color` | hex | Advisory. Six hex digits — see below |
| `document.updated` | timestamp | When this document last changed |
| `document.coverage` | `complete` \| `partial` | Whether this is the whole history |
| `document.canonical` | URL | Where this changelog is published for people to read |
| `document.locale` | BCP 47 | Defaults to `en` |
| `document.older` | URL | **Required when `coverage: partial`.** The next document back — itself a document in this format |

- **`coverage`** is the field consumers most need: it separates "shipped nothing this quarter"
  from "this page only goes back three months". **Absent means unknown**, not `complete`.
  Consumers walking the `older` chain MUST detect cycles.
- **`versioning: semver`** declares SemVer 2.0.0 and switches on the
  [version/content agreement check](#version-and-content-should-agree). `calver` and `none` are
  honest labels that switch nothing on; absent means undeclared, and the check stays off — shape
  alone never opts a document in, because `24.04.1` looks semver enough to misread.
- **The default `id` slug is one algorithm**: lowercase; Unicode NFKD, combining marks dropped;
  every run outside `a–z 0–9` becomes one `-`; trim hyphens. A name that reduces to nothing makes
  `product.id` required. An explicit id SHOULD match `[a-z0-9][a-z0-9-]*`.
- **`platforms` values are a closed set per version**: `windows`, `macos`, `linux`, `ios`,
  `android`, `web`, `playstation`, `xbox`, `switch`. `product.platforms` and the escape hatch's
  `platforms:` both draw from it; an unrecognized value is a validation error, and consumers drop
  the value and keep the entry. An earlier draft also let release headings carry these as tags —
  gone, because platforms are a product fact (multi-platform products keep one changelog per
  platform, or ship the same version everywhere at once), and because the set grows over time,
  which no closed heading vocabulary should.
- **`color`** is the fill a consumer may paint behind the product's name or mark: six hex digits
  (`1a73e8`), one opaque sRGB color, no gradients or alpha, product-level only, no foreground —
  consumers choose their own ink against it. Write it bare: in YAML a bare `#` opens a comment,
  so `color: #1a73e8` is a key with no value — a validation error, and one a validator should
  name. A quoted `"#1a73e8"` is accepted, and consumers strip the `#`. No logos, icon URLs, or
  secondary colors; those are a brand kit, not this field. Note that `theme_color` in a web app
  manifest answers a different question and is not this.
- **`canonical`** is the address a consumer falls back to when an entry has no link of its own.
  Fragments are never fabricated against it — there is no cross-renderer anchor algorithm, and a
  made-up fragment is a guess wearing a URL's clothes. It may name a consumer's page; see
  [Indexing, and the reading copy](#indexing-and-the-reading-copy).
- Unknown keys inside `product.` and `document.` are validation errors, ignored by consumers;
  unknown *top-level* keys draw only a warning. See
  [Evolution](#evolution-and-unknown-vocabulary).

## The release heading

The load-bearing grammar:

```
release-heading := "## " (label-part sep)? date tags?

label-part      := "[" label "](" url ")" | label
label           := version | version ": " title | title
version         := "v"? DIGIT+ ("." DIGIT+)+ ("-" pre)? ("+" build)?
pre             := ident ("." ident)*
build           := ident ("." ident)*
ident           := [0-9A-Za-z-]+
sep             := " — " | " – " | " - "
date            := RFC 3339 full-date ("T" partial-time time-offset)?
tags            := " (" tag (", " tag)* ")"
tag             := [a-z] [a-z0-9-]*
```

Parsed right to left: match `date tags?` anchored at the end of the line, then require what
precedes to be nothing or `label-part sep`. Nothing scans forward, nothing backtracks. `url` is a
CommonMark link destination; `pre` and `build` are semver's own grammars. A label that parses
entirely as `version` is a version; else it splits on the first `": "` into version and title if
the head parses; else it is all title. All four of these are conformant:

```markdown
## [2.4.0](https://kestrel.example/releases/2.4.0) — 2026-07-09
## 2.4.0 — 2026-07-09
## Parallel task graphs — 2026-07-09
## 2026-07-09
```

- **The date is RFC 3339, not "ISO 8601"** — no week dates, ordinal dates or basic format. The
  `T` is required when a time is present; `T` and `Z` are case-insensitive. A date-only value
  means midnight UTC; it is a lossy sort key, so publishers who can emit a timestamp SHOULD.
- **The permalink is optional and recommended.** An entry with no link of its own is addressed by
  the escape hatch's `url:`, else `document.canonical` — skipped by a consumer it names — else
  `product.homepage`.
- **A version is a semver-shaped token or it is not a version.** Anything else is a title;
  versionless and date-only releases are first-class. Identity strips a `v` prefix: `v2.4.1` and
  `2.4.1` are the same version.
- **Pre-releases are semver's job.** `2.5.0-rc.1` *is* a pre-release; there is no flag to agree or
  disagree with it. A versionless pre-release uses `prerelease` in the escape hatch — the only
  case that key exists for. PEP 440 spellings (`1.0rc1`) do not match the grammar and read as
  titles; a Python publisher writes `1.0.0-a1`. A validator SHOULD warn when a pre-release part's
  first identifier is `stable`, `release`, `final`, `ga`, `lts` or `hotfix` — each is a channel
  wearing a pre-release's syntax, and the channel belongs in the escape hatch.

### Tags

An optional parenthesized run at the end of the heading. The vocabulary is closed at every version
of this format — two tokens in v0.1, both lifecycle:

| Tag | Meaning |
| :-- | :-- |
| `yanked` | Do not use this release |
| `routine` | Nothing here a reader needs to read |

An unrecognized tag is a validation error; a consumer that meets one anyway drops the token, keeps
the release, and SHOULD say so. Lifecycle is the rule for what belongs in the run at all: there is
no channel tag and no platform tag. Channels are open-ended and live in the
[escape hatch](#the-escape-hatch), where a typo like `lst` is rejectable instead of silently
accepted; platforms are a product fact stated in the frontmatter, with a per-entry exception in
the hatch.

- **`yanked` means "do not use this release" — not that it was deleted.** The word keeps `cargo
  yank` and PEP 592's meaning: withdrawn from recommendation, not unpublished. Consumers render a
  warning and MUST NOT treat it as a retraction; the entry keeps its notes. A yanked entry SHOULD
  carry `superseded-by`. Yanking is retroactive, and adding the tag MUST NOT change the entry's
  identifier — or every consumer receives the yanked release a second time as a new one.
- **`routine`** marks a release with no user-visible changes worth describing — a dependency bump,
  a store-mandated rebuild. It is the single most useful thing a changelog can tell a reader
  deciding whether to read it, and it replaces the boilerplate-detection heuristics consumers
  otherwise write.

## The entry body

Everything between one `## ` heading and the next.

| Element | Rule |
| :-- | :-- |
| **Summary** | The blockquote directly after the heading (or its escape hatch). One paragraph, plain prose. Never a blockquote opening with `[!` |
| **Change sections** | `### ` whose text is one of the six categories, case-insensitive. Contains a list and nothing else |
| **Change item** | One top-level list item. One distinct change |
| **Everything else** | Kept verbatim as the entry's body |

The six categories are [Keep a Changelog](https://keepachangelog.com)'s, unchanged, and SHOULD
appear in this order — which is not alphabetical:

`Added` · `Changed` · `Deprecated` · `Removed` · `Fixed` · `Security`

The summary is the highest-value optional field in the format: it is what appears in a feed, a
notification, and a search result, and one sentence written on purpose beats any extraction. The
position rule exists because GitHub's `> [!NOTE]` alerts are blockquotes by syntax and must never
become machine summaries. Unrecognized `### ` headings (`### Terminal`, `### Editor`) remain legal
— those documents reach Level 1 and not Level 2. Two shapes are validation errors: the same
category twice in one entry (consumers merge in document order), and change sections in an entry
tagged `routine`.

### Change item text

1. Take the item's **first paragraph** — nested lists and later paragraphs are detail.
2. **Detach the reference tail** into structured references: the trailing run of parenthesized
   reference groups, `#`-prefixed issue references, links whose text is a reference or credit, and
   credits (`@handle`, `thanks to @handle`), with the joining whitespace and commas.
3. **Flatten inline markup** in what remains.

A *reference* is `#` plus digits, a tracker key (`GH-1204`, `KES-88`), or a CVE id. Outside a
parenthesized group or a link, only the `#`-prefixed form counts — that restriction is what keeps
`HTTP-2`, `UTF-8` and `SHA-256` in the prose. **Detached, not discarded**: every token becomes a
structured reference (`issue` / `cve` / `link` / `credit`, with its URL when linked). A security
identifier must end up more reachable than it was in the prose, not gone.

### Breaking changes

A change item MAY open with the literal marker `**Breaking**` — capital B, nothing else inside the
bold — followed by one of the three separators:

```markdown
### Removed

- **Breaking** — the `--serial` flag has been removed; use `--jobs 1`.
```

Inline, inside whichever category the change belongs to, rather than hoisted into a separate
section: a breaking *removal* and a breaking *change* are different things, and a consumer can
build the hoisted view from the inline one but not the reverse. It is a marker, not a severity
scale. Validators SHOULD flag near-misses (`**BREAKING**`, `**Breaking:**`).

### Version and content should agree

Where the document declares `product.versioning: semver` and an entry has a predecessor:

| An entry containing | SHOULD be at least |
| :-- | :-- |
| a `**Breaking**` item | a major bump |
| `### Added` items | a minor bump |
| only `### Fixed` items | a patch bump |

This is Conventional Commits' type-to-SemVer correspondence restated over categories, and the one
check a validator gets free. A consumer must never *derive* a version from content — only flag the
disagreement, and only where the publisher declared the scheme.

## The escape hatch

A fenced block with the info string `changelog`, immediately after the `## ` heading and before
the summary. YAML, under the same [profile](#yaml-profiled) as the frontmatter. Where a key and
the heading state the same fact, **the hatch wins** — one precedence rule, no exceptions. But
`version`, `title` and `date` exist for headings that are wrong and cannot be fixed; a hatch value
contradicting a heading that parses cleanly draws a validator warning, because a document whose
human and machine readings diverge has reintroduced the disease this format treats.

````markdown
## [1.129](https://code.example/updates/v1_129) — 2026-06-11

```changelog
covers: ["1.129.1", "1.129.2"]
```

> …
````

The keys are a closed set:

| Key | For |
| :-- | :-- |
| `channel` | The release channel: `lts`, `insiders`, `beta`, `nightly`, anything the publisher runs |
| `url` | The entry's permalink. Overrides a heading link; idiomatic when the heading has none |
| `prerelease` | Boolean. **Only** for a versionless pre-release — on a versioned entry it is a validation error |
| `platforms` | The entry's platform set, where it differs from the product's — a Linux-only hotfix, say. It scopes the entry, not statements in it |
| `covers` | Other versions this entry documents, which have no entry of their own |
| `superseded-by` | The release a reader on this one should move to |
| `id` | An explicit stable identifier |
| `version`, `title`, `date` | Explicit overrides where the heading is ambiguous or wrong |

### `covers` and `superseded-by` are not inverses

**`covers`** is a *documentation* relationship: the named versions have no entry of their own, and
this entry is where they are written down — the vendor who documents patch releases on the minor
version's page, said explicitly instead of lost. An entry MAY attribute individual change items to
covered versions by opening the item with the version in bold plus a separator — `- **2.3.1** —
…` — where the bold token MUST appear in `covers`.

**`superseded-by`** is a *replacement* relationship: the named release has its own entry and the
publisher is directing readers to it. Three rules: it is meaningful only when the successor is not
simply the next entry (a validator may warn otherwise); it MAY point at an *older* release — at
the moment of a yank, the newest good build is usually the previous one; and it MUST resolve — to
an entry in this document, to a version this document `covers` (landing on the covering entry), or
through the `document.older` chain, which offline validators report as a warning rather than an
error. Dangling in-document is an error.

## Ordering and identity

Entries MUST appear newest first — a truncated read of a reverse-chronological document is still
useful. An entry's identifier is, in order of preference:

1. `id` from the escape hatch
2. `<product.id>@<version>`, `v` prefix stripped
3. `<product.id>@<date>`, date portion only

The date portion *only*, even when the heading carries a time: publishers add times to old entries
when tooling improves, and a time-bearing identifier would re-deliver the whole history that day.
A versionless product releasing twice in one day therefore collides by construction and resolves
it with an explicit `id` — routine, not exceptional, for that publisher. Identifiers are unique
within the document and its archive chain, not globally; two vendors picking the same `product.id`
is the consumer's namespacing problem. **Identifiers MUST be stable across edits** — an entry
whose identifier changes when its prose is corrected is re-delivered to every consumer as new.

## Skipped headings

A `## ` heading is a **release-heading candidate** if its text contains an RFC 3339 full-date
(`\d{4}-\d{2}-\d{2}`) anywhere, and not otherwise.

- **Not a candidate** — `## Unreleased`, any editorial heading — MUST be skipped by consumers, and
  costs nothing at any level. This is what lets Keep a Changelog's `## [Unreleased]` coexist with
  the format.
- **A candidate that does not parse** — `## Release notes for 2026-05-02`, a no-break space in the
  separator, an unpadded date — is skipped too, but is a conformance failure and a validator
  error. A heading with a date in it was almost certainly meant to be a release, and a silent skip
  would let one typo delete it from every aggregator.

Consumers MUST count the headings they skip and SHOULD surface the count — "three releases" and
"three releases, four candidates skipped" are different statements about the same document.

## Conformance

**Level 1 — Structured.** The frontmatter parses under the profile and declares a version whose
major the checker understands; every candidate heading parses; no duplicate identifiers; entries
newest first; no entry's body is only a pointer to notes that live elsewhere. Eliminates date
parsing, version extraction, pre-release inference, title cleaning, identity invention, and the
index-versus-body problem.

**Level 2 — Categorized.** Level 1, plus no top-level list outside a recognized `### ` section in
any entry. The check is structural because no machine can decide whether a paragraph *is* a
change; an entry with no lists at all passes vacuously and contributes no changes, which is
honest. Eliminates the enrichment stage entirely.

Level 1 is a find-and-replace; Level 2 is a decision about how release notes are organized. Most
of the value is in the first. **Addressability** — every entry having its own URL — is a property
validators SHOULD report, not a third level; it cuts across the other two.

One rule carries more weight than it looks: **an entry's content MUST be the release notes, not a
link to them.** A source that resolves is not a source that answers, and this failure is invisible
to every structural signal.

### Reading a damaged document

Validators reject; consumers salvage. A **validator** MUST fail on: YAML outside the profile; an
unknown non-`x-` key, tag or category for the declared version; a candidate heading that does not
parse; duplicate identifiers; entries out of order; an in-document dangling `superseded-by`;
`prerelease:` on a versioned entry; change sections in a `routine` entry; a duplicated category
section; an attribution naming a version the entry does not cover. It SHOULD warn on the legal
near-misses, and diagnose the character when it rejects one.

A **consumer** never rejects a document it can partly read. Damage costs the smallest enclosing
unit: an unparseable candidate heading costs that entry; a malformed hatch costs the hatch; an
unrecognized token costs the token; frontmatter that fails the profile costs the document. What
was dropped, the consumer counts and SHOULD surface.

### Evolution, and unknown vocabulary

Every closed set is closed **per version**. Within a major version, a later minor may add
vocabulary and MUST NOT change the meaning of existing vocabulary. A validator treats vocabulary
from a newer minor as the typo it cannot distinguish it from; a consumer MUST ignore unknown
vocabulary — drop the token or key, keep the construct — and MUST NOT refuse a document over its
minor version. Keys beginning `x-` are permitted everywhere, never validated, ignored unless
recognized.

## Media and raw HTML

Images SHOULD carry meaningful alt text and MUST NOT be load-bearing — consumers routinely render
them as their alt text alone, because remote images are a tracking pixel and an availability
dependency. Raw HTML SHOULD NOT appear: a consumer that sanitizes it will remove it, and one that
does not has a vulnerability.

## Discovery and hosting

This section governs where a document lives and how it is served, not what it says. It adds no
vocabulary, so it applies to a document of any format version, and documents still declare
`changelog: "0.1"`.

### The well-known location

A publisher SHOULD serve its document at `/.well-known/changelog.md` on the origin of
`product.homepage` ([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)). That path names the
product a host is about; [further products](#several-products-on-one-origin) have their own. It
lives under `/.well-known/` rather than at the root
because the root belongs to the site — security.txt made the same move on its way to an RFC.
Nothing needs to link to it, which is what lets a product with no public changelog page publish a
document without publishing a page.

### Several products on one origin

An origin MAY serve a document for each further product at `/.well-known/changelog/{id}.md`, where
`{id}` is that document's `product.id` — stated, or defaulted from `product.name` — and matches
`[a-z0-9][a-z0-9-]*`. The root path keeps the product the origin is about. A consumer that credits
a family path to the origin MUST refuse a document whose `product.id` differs from its path, so the
path says which product it holds and a stray file cannot stand in for another. Redirects and
crediting follow the same rules as the root.

Nothing can list a directory, so the homepage SHOULD link every document on the origin, the root
included, with the link below. Those links are how a consumer learns the family exists:

```html
<link rel="changelog" type="text/markdown" href="/.well-known/changelog.md">
<link rel="changelog" type="text/markdown" href="/.well-known/changelog/kestrel-ios.md">
```

### Advertising a document that lives elsewhere

```html
<link rel="changelog" type="text/markdown" href="https://kestrel.example/changelog.md">
```

A page MAY advertise its product's document with this link in its `<head>`. The `href` may name
any host — a repository's raw file, a docs site, a CDN — because it is credited by the page that
carries it. A page MAY carry one link per product it speaks for: a product page links its own
document, whose `product.homepage` SHOULD be that page, and a homepage may link every document on
its origin. A consumer looking for one product among several reads each and keeps the document
whose `product.name` or `product.homepage` names that product. `rel` is a token list and is matched
as one. Where a link names a document for the same product as the origin's well-known one, the
link is the more specific statement and wins. `changelog` is not yet a registered link relation, and
`version-history` is no substitute: it means a page's own revisions, not a product's releases.

### Serving

- **HTTPS.** A consumer MAY refuse a document served over plain HTTP.
- **`text/markdown; charset=utf-8`** ([RFC 7763](https://www.rfc-editor.org/rfc/rfc7763)) is
  RECOMMENDED, and consumers MUST NOT require it — static hosts serve `.md` under whatever type
  their defaults say. The content decides: a response that is not frontmatter declaring
  `changelog` is not a document, which is also how a consumer recognizes a soft 404.
- **Conditional requests** — `ETag` or `Last-Modified`, answered with `304` — SHOULD be honored. A
  changelog changes a few times a month and is polled every hour.
- **A document MUST NOT vary by requester** — IP address, geography, `User-Agent`, cookies. One
  URL is one document.
- **Consumers MAY stop reading after 1 MiB** and keep what they read; newest-first order is what
  makes a truncated read useful. A longer history SHOULD be split with `coverage: partial` and
  `document.older`.

### Redirects, and what a location proves

Consumers MAY follow redirects. Serving a document at a well-known location — the root path or a
family path — proves control of its origin and never the product's identity. A consumer that treats the location as evidence of who published a
document MUST NOT credit a redirect that leaves the origin's site — its host, give or take a
leading `www.`. A link-advertised document is credited by the link, so it may live, and redirect,
anywhere. Neither proves who wrote the document; that is signing, still out of scope.

### Indexing, and the reading copy

Non-normative. The document is a machine interface, and a publisher MAY serve it with
`X-Robots-Tag: noindex`. `document.canonical` says where people read the changelog, and that can be
a consumer's page — for a product that publishes its notes nowhere else, it is. A consumer that
republishes entries MAY treat a canonical naming it as the publisher's statement that its copy is
the reading copy; without one, it should treat its copy as a mirror and keep it out of search
indexes. A consumer skips a canonical that names itself when it needs a link back to the
publisher, and falls through to `product.homepage`.

## Generating this from Conventional Commits

[Conventional Commits](https://www.conventionalcommits.org) classifies the work an author did;
this format classifies what changed for the reader. The map between them:

| Commit type | Category | Note |
| :-- | :-- | :-- |
| `feat` | `Added` | |
| `fix` | `Fixed` | |
| `perf`, `revert` | `Changed` | |
| **`security`** | `Security` | **Extension** — no upstream type exists |
| **`deprecate`** | `Deprecated` | **Extension** — no upstream type exists |
| **`remove`** | `Removed` | **Extension** — no upstream type exists |
| `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore` | *none* | A release of only these is `routine` |

The three extensions exist because stock Conventional Commits cannot say the three categories a
reader most needs: a security fix lands as `fix:` indistinguishable from a typo correction,
deprecation has no expression at all, and `BREAKING CHANGE` conflates removal with breakage —
`remove:` and `remove!:` separate a removal that breaks people from one that does not. A
`conventional-changelog` preset declares them via the `types` array and sorts sections in the
canonical order, not alphabetically. That the seven hidden types map cleanly onto `routine` is the
strongest independent support that tag has. What generation cannot produce is the summary — an
editorial judgement about which of forty commits mattered — so a generated document is a good
Level 2 document with the best line missing.

### Templates for release tools

The validator's repository keeps [tested configuration](https://github.com/whatsnew-fyi/declarative-changelog/tree/main/templates) that makes common release tools write
this format, using the map above. CI runs each tool over a fixture repository of Conventional
Commits, and the file it writes must reach Level 2 with no warnings.

| Tool | Template | How it conforms |
| :-- | :-- | :-- |
| git-cliff | [`cliff.toml`](https://github.com/whatsnew-fyi/declarative-changelog/blob/main/templates/git-cliff/cliff.toml) | Configuration alone |
| semantic-release | [`release.config.mjs`](https://github.com/whatsnew-fyi/declarative-changelog/blob/main/templates/semantic-release/release.config.mjs) | Configuration alone |
| commit-and-tag-version | [`.versionrc.mjs`](https://github.com/whatsnew-fyi/declarative-changelog/blob/main/templates/commit-and-tag-version/.versionrc.mjs) | Configuration alone |
| release-please | [`release-please/`](https://github.com/whatsnew-fyi/declarative-changelog/tree/main/templates/release-please) | `changelog-sections`, then `normalize` on the release PR |
| Changesets | [`release.yml`](https://github.com/whatsnew-fyi/declarative-changelog/blob/main/templates/changesets/release.yml) | `changeset version`, then `normalize` |
| GoReleaser | [`release.yml`](https://github.com/whatsnew-fyi/declarative-changelog/blob/main/templates/goreleaser/release.yml) | Reads the file: `extract` hands it the tag's entry |

Two of the most used tools cannot be configured into the [heading grammar](#the-release-heading):
release-please writes `## [1.2.0](…) (2026-10-14)`, and Changesets writes an undated `## 1.2.0`.
The validator's `normalize` command repairs what they write, touching only the entries the tool
just added, so history stays byte for byte.

## Out of scope for v0.1

Named, so the omissions read as decisions:

- **Bulk discovery** — [discovery](#discovery-and-hosting) is one document per product, and a
  homepage's links enumerate an origin's documents; a machine-readable index of a vendor's
  documents, and an HTTP `Link` header form of the link, wait for a publisher who needs them.
- **Severity** beyond the Breaking marker.
- **Localization** beyond `document.locale`.
- **Signing and provenance.** A location proves control of an origin, not authorship.
- **How to write good notes** — [Common Changelog](https://common-changelog.org) is that guide.

## Prior art

- **[Keep a Changelog](https://keepachangelog.com)** — the six categories, reverse-chronological
  order, and the ancestor of `yanked`. This standard is largely Keep a Changelog with the
  machine-facing fields made explicit.
- **[AppStream](https://www.freedesktop.org/software/appstream/docs/) release metadata** — a
  decade of structured release data for Linux software centers; its lesson is that such formats
  get tooling-driven adoption, which is the argument for the Conventional Commits path. Its
  external `<releases>` element, pointing component metadata at a separately hosted release file,
  is the precedent for
  [advertising a document that lives elsewhere](#advertising-a-document-that-lives-elsewhere).
- **GitHub Releases and `releases.atom`** — the de facto machine changelog and the incumbent; its
  failure modes (bodies that are one URL, tags with no notes) are this format's motivation.
- **[`cargo yank`](https://doc.rust-lang.org/cargo/commands/cargo-yank.html) and
  [PEP 592](https://peps.python.org/pep-0592/)** — the meaning of `yanked`.
- **[Semantic Versioning](https://semver.org)** — the version grammar, and pre-release semantics
  deferred to it normatively.
- **[Conventional Commits](https://www.conventionalcommits.org)** and
  **[`conventional-changelog`](https://github.com/conventional-changelog/conventional-changelog)**
  — the `**Breaking**` marker's ancestry, the bump correspondence, and the likeliest generator.
- **[towncrier](https://towncrier.readthedocs.io), [changesets](https://github.com/changesets/changesets),
  [release-please](https://github.com/googleapis/release-please)** — the wider generator
  ecosystem, each a plausible emitter.
- **[Common Changelog](https://common-changelog.org)** — prose discipline; complementary.
- **RSS, Atom, [JSON Feed](https://jsonfeed.org)** — solved syndication and identity, but carry no
  categories, versions, platforms, or completeness.
- **Sparkle appcasts** — proof publishers maintain machine-readable release data when something
  they care about depends on it.
- **[llms.txt](https://llmstxt.org)** — the argument that one well-known Markdown document is a
  legitimate machine interface.
- **security.txt ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116))** — one small document at a
  fixed `/.well-known/` path describing the site that serves it: the precedent for
  [the well-known location](#the-well-known-location).

## Open questions

Where this is most likely to be wrong:

1. **Is one version grammar worth its cost?** PEP 440's `1.0rc1` reads as a title; generators can
   normalize, but a normalization is a mapping someone must maintain.
2. **Does `covers` belong in v0.1**, or is it a special case dressed up as a field?
3. **Is `superseded-by` doing two jobs?** "This release is bad, go here" and "the fix shipped over
   there" share one key.
4. **Is `routine` self-defeating?** The best answer so far: generators, not publishers, will set
   it.
5. **Is a bare-date heading too permissive?** Faithful to date-organized publishers, but it gives
   consumers nothing to display except a date.
6. **Is there a seventh category?** `revert` has no home, and `Changed` is a poor fit — a gap
   inherited from Keep a Changelog rather than created here.
7. **Should `product.color` carry a dark-ground variant?** One value chooses ink but cannot always
   be placed.
8. **Maintenance branches have no field.** A channel is not a branch, and consumers reconstruct
   release trains heuristically; a `branch` key is the obvious v0.2 candidate.

## About

Drafted by [What's New](/), where it is argued from measurement: the format's fields are the
facts this site's ingestion pipeline currently reconstructs, per publisher, by hand. The raw
markdown of this page is served at [/spec.md](/spec.md). Feedback to
[contact@whatsnew.fyi](mailto:contact@whatsnew.fyi).

### Publishing to What's New

What's New reads a document in this format as it is written; a Level 2 document reaches its pages
with no model in the path. Once it tracks a document, a publisher can have the next hourly poll
read it rather than waiting for the schedule — one request from a deploy job:

```console
curl -X POST "https://whatsnew.fyi/api/ping?url=https://kestrel.example/.well-known/changelog.md"
```

`202` means the document is queued for the next hourly poll; `404` means What's New does not track
that URL yet — [suggest the product](/suggest) first. An origin that serves several documents
pings each URL that changed. At most one ping per source counts in any five minutes. A ping carries no content, so it changes when the document is read, never what it
says. A release whose document names its What's New page as `document.canonical` is indexed there;
any other is treated as a mirror of a changelog published somewhere else.
