# Declarative Changelogs

**v0.1 — provisional.** 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.

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 | Default platform set for every entry. See [Tags](#tags) |
| `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. One opaque `#RRGGBB` — 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-]*`.
- **`color`** is the fill a consumer may paint behind the product's name or mark: one opaque sRGB
  hex, no gradients or alpha, product-level only, no foreground — consumers choose their own ink
  against it. 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.
- 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`, 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:

| Kind | Values |
| :-- | :-- |
| Lifecycle | `yanked`, `routine` |
| Platform | `windows`, `macos`, `linux`, `ios`, `android`, `web`, `playstation`, `xbox`, `switch` |

An unrecognized tag is a validation error; a consumer that meets one anyway drops the token, keeps
the release, and SHOULD say so. Platform tags replace `product.platforms` outright. There is no
channel 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.

- **`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` | When the tag run would be unwieldy. Replaces the heading's platform tags |
| `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.

## 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.

## Out of scope for v0.1

Named, so the omissions read as decisions:

- **Discovery and hosting** — the obvious v0.2, and roughly half the value. The working candidate
  is a well-known path, `/.well-known/changelog.md`, plus a `rel` link; nothing in v0.1 depends on
  either.
- **Severity** beyond the Breaking marker.
- **Localization** beyond `document.locale`.
- **Signing and provenance.**
- **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.
- **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.

## 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).
